diff --git a/packages/origine2/src/components/MultiSplitPane/MultiSplitPane.tsx b/packages/origine2/src/components/MultiSplitPane/MultiSplitPane.tsx new file mode 100644 index 00000000..f639dfd2 --- /dev/null +++ b/packages/origine2/src/components/MultiSplitPane/MultiSplitPane.tsx @@ -0,0 +1,309 @@ +import React, { useEffect, useRef, useState } from 'react'; +import styles from './multiSplitPane.module.scss'; + +interface MultiSplitPaneProps { + direction: 'horizontal' | 'vertical'; + /** 各段尺寸:固定段为像素数值,最后一段必须是 'flex'(自动填充剩余空间) */ + sizes: Array; + /** 每段的最小尺寸(像素),默认 0 */ + minSizes?: number[]; + /** 受控回调:拖拽后返回各固定段的尺寸(不含 flex 段) */ + onSizesChange?: (sizes: number[]) => void; + /** 分隔条拖拽热区尺寸(像素),默认 4 */ + dividerSize?: number; + /** 分隔条线的颜色,默认使用主题变量 var(--primary) */ + dividerColor?: string; + /** 拖拽时临时禁用 pointer-events 的元素选择器,用于避免 iframe 吞掉 pointermove */ + disablePointerOn?: string; + /** 尺寸持久化的 localStorage key;提供后拖拽结果会被记住,下次挂载自动恢复(受控模式同样生效) */ + persistKey?: string; + /** 是否持久化尺寸(默认 true;仅在提供 persistKey 时生效) */ + persist?: boolean; + children: React.ReactNode[]; +} + +const DEFAULT_DIVIDER_SIZE = 4; + +interface PanelMaxParams { + index: number; + fixed: number[]; + containerSize: number; + dividerCount: number; + dividerSize: number; +} + +/** 计算某个固定段可调整到的最大尺寸:容器尺寸 - 其他固定段之和 - 分隔条总宽(保证 flex 段 ≥ 0) */ +const computePanelMax = ({ index, fixed, containerSize, dividerCount, dividerSize }: PanelMaxParams): number => { + if (containerSize <= 0) return Infinity; + const otherSum = fixed.reduce((acc, s, i) => (i === index ? acc : acc + s), 0); + return containerSize - otherSum - dividerCount * dividerSize; +}; + +interface ClampParams { + sizes: number[]; + containerSize: number; + minSizes: number[]; + dividerCount: number; + dividerSize: number; +} + +/** 把固定段列表钳制到容器内:总宽超出时从最后一个固定段开始往回缩小(各段不低于 min) */ +const clampToContainer = ({ sizes, containerSize, minSizes, dividerCount, dividerSize }: ClampParams): number[] => { + const totalDividers = dividerCount * dividerSize; + const totalFixed = sizes.reduce((acc, s) => acc + s, 0); + if (totalFixed + totalDividers <= containerSize) return sizes; + const next = [...sizes]; + let overflow = totalFixed + totalDividers - containerSize; + for (let i = next.length - 1; i >= 0 && overflow > 0; i--) { + const min = minSizes[i] ?? 0; + const shrink = Math.min(next[i] - min, overflow); + if (shrink > 0) { + next[i] -= shrink; + overflow -= shrink; + } + } + return next; +}; + +/** 分隔条自定义样式变量(粗细 + 颜色),通过 CSS 变量控制 */ +type MultiSplitPaneDividerVars = React.CSSProperties & { + '--msp-divider-size': string; + '--msp-divider-color'?: string; +}; + +/** + * 多段分割面板:拖拽分隔条调整各段尺寸 + */ +export default function MultiSplitPane({ + direction, + sizes, + minSizes = [], + onSizesChange, + persistKey, + persist = true, + dividerSize = DEFAULT_DIVIDER_SIZE, + dividerColor, + disablePointerOn, + children, +}: MultiSplitPaneProps) { + const isHorizontal = direction === 'horizontal'; + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState(0); + const [activeDividerIndex, setActiveDividerIndex] = useState(null); + + // 记录拖拽起始信息 + const dragInfoRef = useRef<{ + index: number; // 被拖拽分隔条对应的固定段索引 + startPos: number; + startSize: number; + } | null>(null); + + // 实时监听容器尺寸 + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = () => { + const size = isHorizontal ? el.clientWidth : el.clientHeight; + setContainerSize(size); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [isHorizontal]); + + // 从 sizes 提取固定段的当前值 + const fixedSizes = sizes.filter((s): s is number => s !== 'flex'); + + const dividerCount = children.length - 1; + + // 用 ref 保存拖拽期间需要读取的最新值,避免 effect 因这些值每次渲染都是新引用而反复重挂监听 + const fixedSizesRef = useRef(fixedSizes); + fixedSizesRef.current = fixedSizes; + const minSizesRef = useRef(minSizes); + minSizesRef.current = minSizes; + const containerSizeRef = useRef(containerSize); + containerSizeRef.current = containerSize; + const onSizesChangeRef = useRef(onSizesChange); + onSizesChangeRef.current = onSizesChange; + // 记录已回写的“容器尺寸 + sizes”组合,避免父组件未应用钳制值时造成无限循环 + const lastClampedKeyRef = useRef(''); + // 最近一次通知出去的固定段尺寸(拖拽结束 / 钳制时用于落盘持久化) + const lastSizesRef = useRef(fixedSizes); + // 已恢复持久化的 key:persistKey 变化(如切换模板)时重新恢复一次 + const restoredKeyRef = useRef(null); + + const handleDividerPointerDown = (index: number) => (e: React.PointerEvent) => { + // 只响应主按键(鼠标左键 / 触摸主触点),避免右键/中键按下误触发拖拽 + if (e.button !== 0) return; + e.preventDefault(); + const pos = isHorizontal ? e.clientX : e.clientY; + dragInfoRef.current = { + index, + startPos: pos, + startSize: fixedSizes[index] ?? 0, + }; + setActiveDividerIndex(index); + }; + + // 拖拽时:锁定光标、禁止选区、禁用 iframe 指针 + // 使用 Pointer Events(pointermove/pointerup/pointercancel)统一处理鼠标与触摸拖拽 + useEffect(() => { + if (activeDividerIndex === null || !dragInfoRef.current) return; + + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + + // 用元素引用保存原始值,避免用 id/className 作 key 时多个元素互相覆盖 + const previousPointerEvents = new Map(); + if (disablePointerOn) { + document.querySelectorAll(disablePointerOn).forEach((el) => { + previousPointerEvents.set(el, el.style.pointerEvents); + el.style.pointerEvents = 'none'; + }); + } + + const moveHandler = (e: PointerEvent) => { + const info = dragInfoRef.current; + if (!info) return; + const pos = isHorizontal ? e.clientX : e.clientY; + const delta = pos - info.startPos; + const fixed = fixedSizesRef.current; + const min = minSizesRef.current[info.index] ?? 0; + // 上限 = 容器 - 其他固定段 - 分隔条总宽,保证最后一个 flex 段至少为 0 + const max = computePanelMax({ + index: info.index, + fixed, + containerSize: containerSizeRef.current, + dividerCount, + dividerSize, + }); + const newSize = Math.min(Math.max(min, info.startSize + delta), Math.max(min, max)); + // 更新对应的固定段尺寸 + const next = fixed.map((s, i) => (i === info.index ? newSize : s)); + lastSizesRef.current = next; + onSizesChangeRef.current?.(next); + }; + + const finishDrag = () => { + setActiveDividerIndex(null); + dragInfoRef.current = null; + if (persistKey && persist) { + try { + localStorage.setItem(persistKey, JSON.stringify(lastSizesRef.current)); + } catch { + /* ignore */ + } + } + }; + + document.addEventListener('pointermove', moveHandler); + document.addEventListener('pointerup', finishDrag); + document.addEventListener('pointercancel', finishDrag); + + return () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + if (disablePointerOn) { + previousPointerEvents.forEach((value, el) => { + el.style.pointerEvents = value; + }); + } + document.removeEventListener('pointermove', moveHandler); + document.removeEventListener('pointerup', finishDrag); + document.removeEventListener('pointercancel', finishDrag); + }; + }, [activeDividerIndex, isHorizontal, disablePointerOn, dividerCount, dividerSize, persistKey, persist]); + + // 持久化恢复 + 容器钳制: + // 1) 每个 persistKey 首次容器量到后,从 localStorage 恢复尺寸并回写(受控模式同样生效); + // 2) 任何时刻若固定段总和超出容器(如窗口缩放),从最后一个固定段开始往回缩小并回写,结果同样落盘。 + useEffect(() => { + if (containerSize <= 0 || !onSizesChange) return; + + let base = fixedSizes; + if (persistKey && persist && restoredKeyRef.current !== persistKey) { + restoredKeyRef.current = persistKey; + let saved: number[] | null = null; + try { + const raw = localStorage.getItem(persistKey); + if (raw !== null) { + const parsed = JSON.parse(raw) as unknown; + if ( + Array.isArray(parsed) && + parsed.length === fixedSizes.length && + parsed.every((n) => typeof n === 'number' && !Number.isNaN(n)) + ) { + saved = parsed; + } + } + } catch { + /* ignore */ + } + if (saved) base = clampToContainer({ sizes: saved, containerSize, minSizes, dividerCount, dividerSize }); + } + + const next = clampToContainer({ sizes: base, containerSize, minSizes, dividerCount, dividerSize }); + if (!next.some((s, i) => s !== fixedSizes[i])) return; + // 父组件未应用钳制值时避免重复回写(sizes 未变则 key 不变) + const key = `${containerSize}|${minSizes.join(',')}|${fixedSizes.join(',')}`; + if (lastClampedKeyRef.current === key) return; + lastClampedKeyRef.current = key; + lastSizesRef.current = next; + onSizesChange(next); + if (persistKey && persist) { + try { + localStorage.setItem(persistKey, JSON.stringify(next)); + } catch { + /* ignore */ + } + } + }, [containerSize, fixedSizes, minSizes, dividerCount, dividerSize, persistKey, persist, onSizesChange]); + + const dividerVars: MultiSplitPaneDividerVars = { + '--msp-divider-size': `${dividerSize}px`, + ...(dividerColor ? { '--msp-divider-color': dividerColor } : {}), + }; + + // 渲染各段 + 分隔条 + const content = []; + for (let i = 0; i < children.length; i++) { + // 判断该段是否固定尺寸(最后一个或显式 flex) + const isFlex = sizes[i] === 'flex' || i === children.length - 1; + const fixedSize = typeof sizes[i] === 'number' ? (sizes[i] as number) : undefined; + const panelStyle = isFlex ? undefined : isHorizontal ? { width: `${fixedSize}px` } : { height: `${fixedSize}px` }; + + content.push( +
+ {children[i]} +
, + ); + + // 段之间插入分隔条 + if (i < dividerCount) { + content.push( +
+
+
, + ); + } + } + + return ( +
+ {content} +
+ ); +} diff --git a/packages/origine2/src/components/MultiSplitPane/multiSplitPane.module.scss b/packages/origine2/src/components/MultiSplitPane/multiSplitPane.module.scss new file mode 100644 index 00000000..c86fa632 --- /dev/null +++ b/packages/origine2/src/components/MultiSplitPane/multiSplitPane.module.scss @@ -0,0 +1,74 @@ +.multiSplitPane { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.horizontal { + flex-direction: row; +} + +.vertical { + flex-direction: column; +} + +.panel { + flex-shrink: 0; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.panelFlex { + flex: 1; + min-width: 0; + min-height: 0; +} + +.divider { + flex-shrink: 0; + overflow: hidden; + -webkit-user-select: none; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + /* 触摸拖拽时禁止浏览器默认滚动/缩放,否则 pointermove 会被系统手势打断 */ + touch-action: none; +} + +.dividerLine { + visibility: hidden; + background: var(--msp-divider-color, var(--primary)); + border-radius: var(--radius-md); +} + +.dividerX { + width: var(--msp-divider-size, 4px); + cursor: col-resize; + padding: 4px 0; +} + +.dividerX .dividerLine { + width: 2px; + height: 100%; +} + +.dividerY { + height: var(--msp-divider-size, 4px); + cursor: row-resize; + padding: 0 4px; +} + +.dividerY .dividerLine { + height: 2px; + width: 100%; +} + +.divider:hover .dividerLine, +.dividerActive .dividerLine { + visibility: visible; +} diff --git a/packages/origine2/src/components/SplitPane/SplitPane.tsx b/packages/origine2/src/components/SplitPane/SplitPane.tsx new file mode 100644 index 00000000..734afc50 --- /dev/null +++ b/packages/origine2/src/components/SplitPane/SplitPane.tsx @@ -0,0 +1,261 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styles from './splitPane.module.scss'; + +interface SplitPaneProps { + direction: 'horizontal' | 'vertical'; + defaultSize?: number; + minSize?: number; + maxSize?: number; + persistKey?: string; + /** 是否持久化尺寸(默认 true;仅在提供 persistKey 时生效) */ + persist?: boolean; + /** 折叠:固定面板尺寸强制为 0 并隐藏分隔条;取消折叠后恢复记忆尺寸(受控/非受控均可用) */ + collapsed?: boolean; + /** 受控模式:由外部控制固定面板尺寸(传入 value 时启用,此时默认忽略 defaultSize/persistKey) */ + value?: number; + /** 受控模式下拖拽尺寸变化回调 */ + onChange?: (size: number) => void; + /** 固定尺寸的面板:first(第一个,默认)/ second(第二个) */ + fixedPanel?: 'first' | 'second'; + /** 分隔条拖拽热区尺寸(像素),默认 4 */ + dividerSize?: number; + /** 分隔条线的颜色,默认使用主题变量 var(--primary) */ + dividerColor?: string; + /** 拖拽时临时禁用 pointer-events 的元素选择器,用于避免 iframe 吞掉 pointermove */ + disablePointerOn?: string; + /** 拖拽结束后的回调(显示当前尺寸) */ + onResize?: (size: number) => void; + children: [React.ReactNode, React.ReactNode]; +} + +const DEFAULT_DIVIDER_SIZE = 4; + +/** 分隔条自定义样式变量(粗细 + 颜色),通过 CSS 变量控制 */ +type DividerVars = React.CSSProperties & { + '--splitpane-divider-size': string; + '--splitpane-divider-color'?: string; +}; + +/** + * 分割面板:拖拽分隔条调整两个面板大小 + */ +export default function SplitPane({ + direction, + defaultSize = 300, + minSize = 100, + maxSize = Infinity, + persistKey, + persist = true, + collapsed, + value, + onChange, + disablePointerOn, + onResize, + fixedPanel = 'first', + dividerSize = DEFAULT_DIVIDER_SIZE, + dividerColor, + children, +}: SplitPaneProps) { + const isHorizontal = direction === 'horizontal'; + const isControlled = value !== undefined; + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState(0); + const didRestoreRef = useRef(false); + + // 实际可用的最大尺寸 = min(用户 maxSize, 容器尺寸 - 分隔条) + const effectiveMax = Math.max(minSize, Math.min(maxSize, containerSize - dividerSize)); + + // 实时监听容器尺寸,用于把面板大小钳制在容器范围内 + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const measure = () => { + const size = isHorizontal ? el.clientWidth : el.clientHeight; + setContainerSize(size); + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => observer.disconnect(); + }, [isHorizontal]); + + // 非受控模式:内部维护尺寸 + 可选持久化 + const [size, setSize] = useState(defaultSize); + + // 非受控:首次容器尺寸就绪后,从 localStorage 恢复持久化值,并钳制到有效范围 + useEffect(() => { + if (isControlled || containerSize === 0 || didRestoreRef.current) return; + didRestoreRef.current = true; + let target = defaultSize; + if (persistKey && persist) { + // localStorage 在隐私模式/禁用存储时会抛异常,静默降级为默认尺寸 + try { + const saved = localStorage.getItem(persistKey); + // 不能用 `parseInt(saved, 10) || defaultSize`:折叠状态(尺寸 0)会被 `0 || ...` 误判为无效值而丢失 + if (saved !== null) { + const parsed = parseInt(saved, 10); + if (!Number.isNaN(parsed)) target = parsed; + } + } catch { + /* ignore */ + } + } + setSize(Math.max(minSize, Math.min(target, effectiveMax))); + }, [isControlled, containerSize, effectiveMax, minSize, persistKey, persist, defaultSize]); + + // 非受控:容器尺寸后续变化时,只钳制当前值,不重新恢复 + useEffect(() => { + if (isControlled || !didRestoreRef.current) return; + setSize((prev) => Math.max(minSize, Math.min(prev, effectiveMax))); + }, [isControlled, effectiveMax, minSize]); + + // 受控模式:容器尺寸变化时把 value 钳制到有效范围并回写 onChange。 + // 拖拽过程中的钳制由 moveHandler 负责;这里处理窗口缩放等外部变化导致 value 越界的情况。 + // value === 0 表示折叠状态(如调试器收起),不强制钳制到 minSize。 + useEffect(() => { + if (!isControlled || containerSize === 0 || value === 0) return; + const clamped = Math.max(minSize, Math.min(value, effectiveMax)); + if (clamped !== value) onChange?.(clamped); + }, [isControlled, value, containerSize, minSize, effectiveMax, onChange]); + + // 当前生效的尺寸:折叠时强制为 0(隐藏分隔条);否则受控用 value,非受控用内部 size + const currentSize = collapsed ? 0 : isControlled ? value : size; + // 折叠状态:固定面板尺寸为 0 时,隐藏分隔条 + const isCollapsed = currentSize === 0; + + const [isDragging, setIsDragging] = useState(false); + const startPosRef = useRef(0); + const startSizeRef = useRef(currentSize); + const lastSizeRef = useRef(currentSize); + + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { + // 只响应主按键(鼠标左键 / 触摸主触点),避免右键/中键按下误触发拖拽 + if (e.button !== 0) return; + e.preventDefault(); + startPosRef.current = isHorizontal ? e.clientX : e.clientY; + startSizeRef.current = currentSize; + setIsDragging(true); + }, + [isHorizontal, currentSize], + ); + + // 拖拽时:锁定光标、禁止选区、禁用 iframe 指针(防拖拽穿透到 iframe 内容) + // 使用 Pointer Events(pointermove/pointerup/pointercancel)统一处理鼠标与触摸拖拽 + useEffect(() => { + if (!isDragging) return; + + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize'; + document.body.style.userSelect = 'none'; + + // 用元素引用保存原始值,避免用 id/className 作 key 时多个元素互相覆盖 + const previousPointerEvents = new Map(); + if (disablePointerOn) { + document.querySelectorAll(disablePointerOn).forEach((el) => { + previousPointerEvents.set(el, el.style.pointerEvents); + el.style.pointerEvents = 'none'; + }); + } + + const moveHandler = (e: PointerEvent) => { + const pos = isHorizontal ? e.clientX : e.clientY; + const delta = pos - startPosRef.current; + // 固定第二个面板时,拖拽方向与尺寸变化相反(分隔条下移 → 第二个面板变小) + const sizedDelta = fixedPanel === 'second' ? -delta : delta; + const newSize = Math.min(Math.max(startSizeRef.current + sizedDelta, minSize), effectiveMax); + lastSizeRef.current = newSize; + if (isControlled) { + onChange?.(newSize); + } else { + setSize(newSize); + } + }; + // 拖拽结束 + const finishDrag = () => { + setIsDragging(false); + if (!isControlled && persistKey && persist) { + try { + localStorage.setItem(persistKey, lastSizeRef.current.toString()); + } catch { + /* ignore */ + } + } + }; + + document.addEventListener('pointermove', moveHandler); + document.addEventListener('pointerup', finishDrag); + document.addEventListener('pointercancel', finishDrag); + + return () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + if (disablePointerOn) { + previousPointerEvents.forEach((value, el) => { + el.style.pointerEvents = value; + }); + } + document.removeEventListener('pointermove', moveHandler); + document.removeEventListener('pointerup', finishDrag); + document.removeEventListener('pointercancel', finishDrag); + }; + }, [ + isDragging, + isHorizontal, + minSize, + effectiveMax, + persistKey, + persist, + disablePointerOn, + isControlled, + onChange, + fixedPanel, + ]); + + // 拖拽结束时通知外部当前尺寸(用于调试/显示当前尺寸) + const wasDraggingRef = useRef(false); + useEffect(() => { + if (wasDraggingRef.current && !isDragging) { + onResize?.(currentSize); + } + wasDraggingRef.current = isDragging; + }, [isDragging, currentSize, onResize]); + + const fixedStyle = isHorizontal ? { width: `${currentSize}px` } : { height: `${currentSize}px` }; + const isFirstFixed = fixedPanel === 'first'; + + // 分隔条自定义样式(粗细 + 颜色) + const dividerVars: DividerVars = { + '--splitpane-divider-size': `${dividerSize}px`, + ...(dividerColor ? { '--splitpane-divider-color': dividerColor } : {}), + }; + + return ( +
+
+ {children[0]} +
+ {!isCollapsed && ( +
+
+
+ )} +
+ {children[1]} +
+
+ ); +} diff --git a/packages/origine2/src/components/SplitPane/SplitPaneCornerHandle.tsx b/packages/origine2/src/components/SplitPane/SplitPaneCornerHandle.tsx new file mode 100644 index 00000000..7a02f1ce --- /dev/null +++ b/packages/origine2/src/components/SplitPane/SplitPaneCornerHandle.tsx @@ -0,0 +1,98 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import styles from './splitPaneCornerHandle.module.scss'; + +interface SplitPaneCornerHandleProps { + x: number; + y: number; + cursor?: 'nwse-resize' | 'nesw-resize'; + onResizeStart?: () => void; + onResize: (deltaX: number, deltaY: number) => void; + onResizeEnd?: () => void; + disablePointerOn?: string; +} + +export default function SplitPaneCornerHandle({ + x, + y, + cursor = 'nwse-resize', + onResizeStart, + onResize, + onResizeEnd, + disablePointerOn, +}: SplitPaneCornerHandleProps) { + const [isDragging, setIsDragging] = useState(false); + const startPosRef = useRef({ x: 0, y: 0 }); + + const onResizeRef = useRef(onResize); + const onResizeEndRef = useRef(onResizeEnd); + const onResizeStartRef = useRef(onResizeStart); + + useEffect(() => { + onResizeRef.current = onResize; + onResizeEndRef.current = onResizeEnd; + onResizeStartRef.current = onResizeStart; + }); + + const handlePointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + startPosRef.current = { x: e.clientX, y: e.clientY }; + setIsDragging(true); + onResizeStartRef.current?.(); + }, []); + + useEffect(() => { + if (!isDragging) return; + + const previousCursor = document.body.style.cursor; + const previousUserSelect = document.body.style.userSelect; + document.body.style.cursor = cursor; + document.body.style.userSelect = 'none'; + + const previousPointerEvents = new Map(); + if (disablePointerOn) { + document.querySelectorAll(disablePointerOn).forEach((el) => { + previousPointerEvents.set(el, el.style.pointerEvents); + el.style.pointerEvents = 'none'; + }); + } + + const handleMove = (e: PointerEvent) => { + const deltaX = e.clientX - startPosRef.current.x; + const deltaY = e.clientY - startPosRef.current.y; + onResizeRef.current(deltaX, deltaY); + }; + + const handleUp = () => { + setIsDragging(false); + onResizeEndRef.current?.(); + }; + + document.addEventListener('pointermove', handleMove); + document.addEventListener('pointerup', handleUp); + document.addEventListener('pointercancel', handleUp); + + return () => { + document.body.style.cursor = previousCursor; + document.body.style.userSelect = previousUserSelect; + previousPointerEvents.forEach((value, el) => { + el.style.pointerEvents = value; + }); + document.removeEventListener('pointermove', handleMove); + document.removeEventListener('pointerup', handleUp); + document.removeEventListener('pointercancel', handleUp); + }; + }, [isDragging, disablePointerOn]); + + return ( +
e.stopPropagation()} + > +
+
+ ); +} diff --git a/packages/origine2/src/components/SplitPane/splitPane.module.scss b/packages/origine2/src/components/SplitPane/splitPane.module.scss new file mode 100644 index 00000000..8106afd5 --- /dev/null +++ b/packages/origine2/src/components/SplitPane/splitPane.module.scss @@ -0,0 +1,76 @@ +.splitPane { + display: flex; + flex: 1; + width: 100%; + height: 100%; + min-height: 0; + overflow: hidden; +} + +.horizontal { + flex-direction: row; +} + +.vertical { + flex-direction: column; +} + +.panel { + flex-shrink: 0; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.panelFlex { + flex: 1; + min-width: 0; + min-height: 0; +} + +.divider { + flex-shrink: 0; + overflow: hidden; + -webkit-user-select: none; + user-select: none; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + /* 触摸拖拽时禁止浏览器默认滚动/缩放,否则 pointermove 会被系统手势打断 */ + touch-action: none; +} + +.dividerLine { + visibility: hidden; + background: var(--splitpane-divider-color, var(--primary)); + border-radius: var(--radius-md); +} + + +.dividerX { + width: var(--splitpane-divider-size, 4px); + cursor: col-resize; + padding: 4px 0; +} + +.dividerX .dividerLine { + width: 2px; + height: 100%; +} + +.dividerY { + height: var(--splitpane-divider-size, 4px); + cursor: row-resize; + padding: 0 4px; +} + +.dividerY .dividerLine { + height: 2px; + width: 100%; +} + +.divider:hover .dividerLine, +.dividerActive .dividerLine { + visibility: visible; +} diff --git a/packages/origine2/src/components/SplitPane/splitPaneCornerHandle.module.scss b/packages/origine2/src/components/SplitPane/splitPaneCornerHandle.module.scss new file mode 100644 index 00000000..15dc4f7f --- /dev/null +++ b/packages/origine2/src/components/SplitPane/splitPaneCornerHandle.module.scss @@ -0,0 +1,27 @@ +.cornerHandle { + position: absolute; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + transform: translate(-50%, -50%); + z-index: 10; + touch-action: none; + pointer-events: auto; +} + +.cornerHandleDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--primary); + opacity: 0; + transition: opacity 0.15s ease; + pointer-events: none; +} + +.cornerHandle:hover .cornerHandleDot, +.cornerHandleActive .cornerHandleDot { + opacity: 1; +} diff --git a/packages/origine2/src/pages/editor/Editor.tsx b/packages/origine2/src/pages/editor/Editor.tsx index a962e5fd..33cd0e6c 100644 --- a/packages/origine2/src/pages/editor/Editor.tsx +++ b/packages/origine2/src/pages/editor/Editor.tsx @@ -1,13 +1,15 @@ -import TopBar from "./Topbar/Topbar"; -import styles from "./editor.module.scss"; -import EditorSideBar from "./EditorSidebar/EditorSidebar"; -import MainArea from "./MainArea/MainArea"; -import useEditorStore from "@/store/useEditorStore"; -import { useGameEditorContext } from "@/store/useGameEditorStore"; -import FastPreviewTimeoutDialog from "./FastPreviewTimeoutDialog"; +import TopBar from './Topbar/Topbar'; +import styles from './editor.module.scss'; +import EditorSideBar from './EditorSidebar/EditorSidebar'; +import MainArea from './MainArea/MainArea'; +import SplitPane from '@/components/SplitPane/SplitPane'; +import useEditorStore from '@/store/useEditorStore'; +import { useGameEditorContext } from '@/store/useGameEditorStore'; +import FastPreviewTimeoutDialog from './FastPreviewTimeoutDialog'; export default function Editor() { const isAutoHideToolbar = useEditorStore.use.isAutoHideToolbar(); + const isShowSidebar = useGameEditorContext((state) => state.isShowSidebar); const updateCurrentTopbarTab = useGameEditorContext((state) => state.updateCurrentTopbarTab); const handleMainAreaClick = () => isAutoHideToolbar && updateCurrentTopbarTab(null); @@ -16,9 +18,19 @@ export default function Editor() {
-
handleMainAreaClick()}> - - +
handleMainAreaClick()}> + + + +
); diff --git a/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx b/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx index 5abfc80f..9c40b340 100644 --- a/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx +++ b/packages/origine2/src/pages/editor/EditorSidebar/EditorSidebar.tsx @@ -1,21 +1,27 @@ -import styles from "./editorSidebar.module.scss"; -import Assets, { IFileConfig, IFileFunction } from "@/components/Assets/Assets"; -import React, { useEffect, useRef } from "react"; -import { eventBus } from "@/utils/eventBus"; -import { Button, Switch, Tab, TabList } from "@fluentui/react-components"; -import useEditorStore from "@/store/useEditorStore"; -import { useGameEditorContext } from "@/store/useGameEditorStore"; -import { IGameEditorSidebarTabs, ITag } from "@/types/gameEditor"; -import { t } from "@lingui/macro"; -import { ArrowClockwiseFilled, ArrowClockwiseRegular, LiveFilled, LiveOffFilled, LiveOffRegular, LiveRegular, OpenFilled, OpenRegular, bundleIcon } from "@fluentui/react-icons"; -import { EditorPreviewClient } from "@/utils/editorPreviewClient"; -import { createPreviewBootstrapProvide, isPreviewBootstrapRequest } from "@/utils/editorPreviewBootstrap"; +import styles from './editorSidebar.module.scss'; +import Assets, { IFileConfig, IFileFunction } from '@/components/Assets/Assets'; +import { useEffect, useRef } from 'react'; +import { eventBus } from '@/utils/eventBus'; +import { Button, Switch, Tab, TabList } from '@fluentui/react-components'; +import useEditorStore from '@/store/useEditorStore'; +import { useGameEditorContext } from '@/store/useGameEditorStore'; +import { IGameEditorSidebarTabs, ITag } from '@/types/gameEditor'; +import { t } from '@lingui/macro'; +import { + ArrowClockwiseFilled, + ArrowClockwiseRegular, + LiveFilled, + LiveOffFilled, + LiveOffRegular, + LiveRegular, + OpenFilled, + OpenRegular, + bundleIcon, +} from '@fluentui/react-icons'; +import { EditorPreviewClient } from '@/utils/editorPreviewClient'; +import { createPreviewBootstrapProvide, isPreviewBootstrapRequest } from '@/utils/editorPreviewBootstrap'; import { createId } from '@/utils/createId'; -let startX = 0; -let prevXvalue = 0; -let isMouseDown = false; - const ArrowClockwiseIcon = bundleIcon(ArrowClockwiseFilled, ArrowClockwiseRegular); const OpenIcon = bundleIcon(OpenFilled, OpenRegular); const LiveIcon = bundleIcon(LiveFilled, LiveRegular); @@ -106,57 +112,6 @@ export default function EditorSideBar() { EditorPreviewClient.setFontOptimization(isUseFontOptimization); }, [isUseFontOptimization]); - useEffect(() => { - const storeWidth = localStorage.getItem('sidebar-width'); - if (!storeWidth) { - const initWidth = window.innerWidth * 0.35; - localStorage.setItem('sidebar-width', initWidth.toString()); - document.body.style.setProperty("--sidebar-width", `${initWidth}px`); - } else { - document.body.style.setProperty("--sidebar-width", `${storeWidth}px`); - } - }, []); - - const handleDragStart = (event: React.MouseEvent) => { - startX = event.clientX; - const prevX = document.body.style.getPropertyValue("--sidebar-width"); - prevXvalue = parseInt(prevX.substring(0, prevX.length - 2), 10); - isMouseDown = true; - const previewFrame = document.getElementById("gamePreviewIframe"); - if (previewFrame) - previewFrame.style.pointerEvents = 'none'; - }; - - const handleDrag = (event: MouseEvent) => { - if (isMouseDown) { - const deltaX = event.clientX - (startX); - const newValue = prevXvalue + deltaX; - document.body.style.setProperty("--sidebar-width", `${(newValue < 240) ? 240 : newValue}px`); - } - - }; - - const handleDragEnd = (_event: MouseEvent) => { - setTimeout(() => { - const prevX = document.body.style.getPropertyValue("--sidebar-width"); - prevXvalue = parseInt(prevX.substring(0, prevX.length - 2), 10); - localStorage.setItem('sidebar-width', prevXvalue.toString()); - }, 10); - isMouseDown = false; - const previewFrame = document.getElementById("gamePreviewIframe"); - if (previewFrame) - previewFrame.style.pointerEvents = 'auto'; - }; - - useEffect(() => { - window.addEventListener('mousemove', handleDrag); - window.addEventListener('mouseup', handleDragEnd); - return () => { - window.removeEventListener('mousemove', handleDrag); - window.removeEventListener('mouseup', handleDragEnd); - }; - }, []); - const refreshGame = () => { embeddedLaunchIdRef.current = createId(); ifRef.current?.contentWindow?.location.reload(); @@ -221,7 +176,10 @@ export default function EditorSideBar() { }; const assetsTabs = ( - updateCurrentSidebarTab(data.value as unknown as IGameEditorSidebarTabs)} > {t`资源`} @@ -229,90 +187,84 @@ export default function EditorSideBar() { ); - return <> - {isShowSidebar && -
-
-
- updateIsShowPreview(!isShowPreview)} - /> -
-
+
+ {isShowPreview && ( + // eslint-disable-next-line react/iframe-missing-sandbox +