Skip to content

Commit 406a9ca

Browse files
wehosHongzhi Wenclaude
authored
feat(compact): 历史堆砌区支持顶部 resize bar 调整高度上限 (#1664)
* feat(compact): 历史堆砌区支持顶部 resize bar 调整高度上限 compact 紧凑态下历史对话气泡堆叠在 CompactExportHistoryPanel,其高度上限原本写死在 --compact-export-history-region-height(width*1.18 / 63vh),用户只能调宽度(左右 handle)、 无法调高度,想把历史区压矮节约屏幕没有任何入口。 在堆砌区顶部新增一条横向 resize bar(平时透明、hover 半透明显现、拖动中保持可见), 上下拖拽覆盖预留变量 --compact-history-slot-height: - 范围 120px ~ min(width*1.46, 78% 视口/工作区),初始未拖动保持默认高度 - React localStorage 持久化(neko.reactChatWindow.compactHistorySlotHeight) - 拖动复用宿主 neko:compact-interaction-geometry-refresh 让 Electron 窗口重算 bounds / 命中区 / 穿透 - bar 带 data-compact-hit-region + no-drag,Electron 下可点不穿透、不触发拖窗 - compact 单行小胶囊本身不动 补 vitest(bar 渲染/hit-region/is-active)与 pytest static(样式/DOM/storage key/几何事件)断言。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 回应 review — 修 resize bar 的 Electron 命中 / 起拖死区 / 上限溢出 - bar 透明从本体移到 ::after 伪元素:宿主几何收集器(app-react-chat-window.js)按 computed opacity<=0.01 丢弃 hit-region,bar 本体透明会让 Electron 下整条鼠标穿透、永远 hover/点不到、 无法发起拖拽。改后本体保持不透明、只让视觉抓手伪元素平时透明。 - 起拖基准对持久高度 clamp:存量值来自更大屏 / 更宽 surface 时面板已钳到 max,用 stale 大值做 基准会出现「先拖一段没反应」的死区。并新增监听 neko:compact-surface-resize-width-change, 宽度变化后按新约束重写 CSS 变量(刻意不改 state / 不覆盖 storage,保留跨屏 / 跨宽度高度记忆)。 - max 扣掉 scroll 上方 bar + 下方 controls 的固定 chrome(72px),避免拖到上限时 scroll 吃满 anchor、controls / 底部气泡溢出被 Electron 几何裁成非交互。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(compact): 回应 review — under-choice 态禁用 bar + 纯点击不落库 - choice prompt 压在历史上方(choiceLayerAbove)时,bar 的 hit-region gate 在 !choiceLayerAbove, 并纳入 under-choice 的 pointer-events:none / 淡化列表。原本该态历史区整体惰性,但 bar 仍保持 pointer-events:auto + 上报命中区,会在 Electron 下留一条可拖、不穿透的活条干扰 choice UI。 - resize state 加 moved 标志,finish 只在真正拖动过才 persist:纯点击 bar 不再把响应式 CSS 默认 高度(width*1.18 / 视口比)锁成固定像素值(否则之后视口 / compact 宽度变化不再驱动面板高度)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(compact): 补 resize bar under-choice 用例的 hit-region-kind 断言 回应 CodeRabbit nitpick:组件三个 hit-region 属性(region / id / kind)同条件 gate 在 !choiceLayerAbove,测试补齐 data-compact-hit-region-kind=null,与已有 id / region 两条对齐, 覆盖完整命中区契约。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(compact): resize bar 改为「调尺寸热区」联动浮现 把顶部高度 bar 的浮现触发从「hover 整个历史区」收窄为:鼠标进入左右宽度 handle (.app-shell:has(.compact-chat-resize-handle:hover),与 anchor 同在 app-shell 下,跨子树 :has) 或历史滚动条命中区(.compact-export-history-anchor:has(.compact-export-history-scrollbar-hit:hover)) 时连带显现;bar 本体 hover / 拖动中(is-active)保留。这样只在「调宽 / 滚动」语境里统一提示也能 调高度,平时不再因鼠标划过历史区就亮,less intrusive。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Hongzhi Wen <cartabio.coder1@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent de8f2ec commit 406a9ca

5 files changed

Lines changed: 356 additions & 0 deletions

File tree

frontend/react-neko-chat/src/App.tsx

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const COMPACT_SPEECH_FALLBACK_REVEAL_DELAY_MS = 700;
9696
const SPEECH_PLAYBACK_STATE_STORAGE_KEY = 'neko_speech_playback_state';
9797
const SPEECH_PLAYBACK_CHANNEL_NAME = 'neko_speech_playback_channel';
9898
const COMPACT_EXPORT_HISTORY_OPEN_STORAGE_KEY = 'neko.reactChatWindow.compactExportHistoryOpen';
99+
const COMPACT_HISTORY_HEIGHT_STORAGE_KEY = 'neko.reactChatWindow.compactHistorySlotHeight';
99100
export const COMPACT_EXPORT_HISTORY_VISIBILITY_ANIMATION_MS = 560;
100101
const COMPACT_INPUT_TOOL_WHEEL_ITEM_COUNT = 7;
101102
const COMPACT_INPUT_TOOL_WHEEL_DRAG_THRESHOLD = 22;
@@ -130,6 +131,17 @@ const COMPACT_SURFACE_RESIZE_MOBILE_MIN_WIDTH = 280;
130131
const COMPACT_SURFACE_RESIZE_MAX_WIDTH = 720;
131132
const COMPACT_SURFACE_RESIZE_VIEWPORT_GUTTER = 32;
132133
const COMPACT_SURFACE_RESIZE_MOBILE_VIEWPORT_GUTTER = 16;
134+
// compact 历史堆砌区(CompactExportHistoryPanel)顶部 resize bar 的高度上限钳位参数。
135+
// 下限压到 ~1-2 个气泡以便节约屏幕;上限对齐 anchor 的 max-height(width*1.46 / 78% 视口),
136+
// 避免拖超 anchor 二次截断产生「拖了没反应」的死区。默认(未拖动)公式仍是 width*1.18 / 63%。
137+
const COMPACT_HISTORY_SLOT_MIN_HEIGHT = 120;
138+
const COMPACT_HISTORY_SLOT_MAX_WIDTH_RATIO = 1.46;
139+
const COMPACT_HISTORY_SLOT_MAX_VIEWPORT_RATIO = 0.78;
140+
const COMPACT_HISTORY_SLOT_DEFAULT_WIDTH_RATIO = 1.18;
141+
const COMPACT_HISTORY_SLOT_DEFAULT_VIEWPORT_RATIO = 0.63;
142+
// scroll 区上方的 bar(12px+margin) 与下方 controls(展开块 ≤44px) 的固定 chrome;
143+
// 从 anchor max-height 里扣掉,避免拖到上限时 scroll 吃满 anchor、controls 溢出被裁成非交互。
144+
const COMPACT_HISTORY_SLOT_CHROME_RESERVE = 72;
133145
const COMPACT_CHOICE_PLACEMENT_HYSTERESIS = 24;
134146
const COMPOSER_OPTION_MARQUEE_MIN_DISTANCE = 6;
135147
const COMPOSER_OPTION_MARQUEE_MIN_DURATION_MS = 1400;
@@ -152,6 +164,15 @@ type CompactSurfaceResizeState = {
152164
captureTarget: Element | null;
153165
};
154166

167+
type CompactHistoryResizeState = {
168+
pointerId: number;
169+
startPointerY: number;
170+
startHeight: number;
171+
lastHeight: number;
172+
moved: boolean;
173+
captureTarget: Element | null;
174+
};
175+
155176
function createCompactHistoryDropRequestId() {
156177
return `compact-history-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
157178
}
@@ -373,6 +394,53 @@ function persistCompactExportHistoryOpen(open: boolean) {
373394
}
374395
}
375396

397+
function readPersistedCompactHistorySlotHeight(): number | null {
398+
if (typeof window === 'undefined') return null;
399+
try {
400+
const persisted = window.localStorage?.getItem(COMPACT_HISTORY_HEIGHT_STORAGE_KEY);
401+
if (persisted === null || persisted === undefined) return null;
402+
const value = Number(persisted);
403+
return Number.isFinite(value) && value > 0 ? value : null;
404+
} catch {
405+
return null;
406+
}
407+
}
408+
409+
function persistCompactHistorySlotHeight(value: number | null) {
410+
if (typeof window === 'undefined') return;
411+
try {
412+
if (value === null) {
413+
window.localStorage?.removeItem(COMPACT_HISTORY_HEIGHT_STORAGE_KEY);
414+
} else {
415+
window.localStorage?.setItem(COMPACT_HISTORY_HEIGHT_STORAGE_KEY, String(Math.round(value)));
416+
}
417+
} catch {
418+
// localStorage can be unavailable in restricted hosts; keep the in-memory state.
419+
}
420+
}
421+
422+
// 历史区高度上限的基数:Electron 独立窗口用工作区高度(窗口可能只覆盖部分屏,不能用 innerHeight),
423+
// 网页路径用视口高度。与 styles.css 里默认公式的 63vh / workarea*0.63 取同一基数。
424+
function getCompactHistoryViewportBase(): number {
425+
if (typeof window === 'undefined') return 900;
426+
const desktopLayout = (window as typeof window & {
427+
__nekoDesktopCompactLayout?: { workArea?: { height?: number } | null } | null;
428+
}).__nekoDesktopCompactLayout;
429+
const workAreaHeight = Number(desktopLayout?.workArea?.height);
430+
if (isDesktopCompactSurfaceLayoutActive() && Number.isFinite(workAreaHeight) && workAreaHeight > 0) {
431+
return workAreaHeight;
432+
}
433+
return window.innerHeight || 900;
434+
}
435+
436+
function getCompactHistoryResizePointerY(event: ReactPointerEvent<HTMLDivElement>): number {
437+
const screenY = Number(event.screenY);
438+
if (Number.isFinite(screenY)) {
439+
return screenY;
440+
}
441+
return event.clientY;
442+
}
443+
376444
type SpeechPlaybackState = {
377445
active: boolean;
378446
turnId?: string | null;
@@ -1284,13 +1352,16 @@ export default function App({
12841352
const [compactInputToolWheelChargeDirection, setCompactInputToolWheelChargeDirection] = useState<1 | -1 | null>(null);
12851353
const [compactInputToolWheelChargeReleaseActive, setCompactInputToolWheelChargeReleaseActive] = useState(false);
12861354
const [compactSurfaceResizeWidth, setCompactSurfaceResizeWidth] = useState<number | null>(null);
1355+
const [compactHistorySlotHeight, setCompactHistorySlotHeight] = useState<number | null>(readPersistedCompactHistorySlotHeight);
1356+
const [compactHistoryResizeActive, setCompactHistoryResizeActive] = useState(false);
12871357
const [compactExportHistoryOpen, setCompactExportHistoryOpen] = useState(readPersistedCompactExportHistoryOpen);
12881358
const [compactExportHistoryMounted, setCompactExportHistoryMounted] = useState(readPersistedCompactExportHistoryOpen);
12891359
const [compactExportControlsOpen, setCompactExportControlsOpen] = useState(false);
12901360
const [compactExportPreviewOpen, setCompactExportPreviewOpen] = useState(false);
12911361
const [compactExportSelectedIds, setCompactExportSelectedIds] = useState<Set<string>>(() => new Set());
12921362
const [compactExportAutoScrollToBottom, setCompactExportAutoScrollToBottom] = useState(true);
12931363
const compactSurfaceResizeStateRef = useRef<CompactSurfaceResizeState | null>(null);
1364+
const compactHistoryResizeStateRef = useRef<CompactHistoryResizeState | null>(null);
12941365
const compactHistoryVisibilitySuppressClickRef = useRef(false);
12951366
const compactExportHistoryUnmountTimerRef = useRef<number | null>(null);
12961367
const submittingRef = useRef(false);
@@ -2732,6 +2803,149 @@ export default function App({
27322803
finishCompactSurfaceResize(event);
27332804
}, [finishCompactSurfaceResize]);
27342805

2806+
const getCompactHistorySlotMaxHeight = useCallback(() => {
2807+
const surfaceWidth = getCurrentCompactSurfaceWidth();
2808+
const base = getCompactHistoryViewportBase();
2809+
// anchor 的 max-height = min(width*1.46, 78%),但 panel 里 scroll 上方有 bar、下方有 controls;
2810+
// 先扣掉这部分非滚动 chrome,scroll 区才不会吃满 anchor 把 controls / 底部气泡顶出可视/可点区。
2811+
const anchorMax = Math.min(
2812+
surfaceWidth * COMPACT_HISTORY_SLOT_MAX_WIDTH_RATIO,
2813+
base * COMPACT_HISTORY_SLOT_MAX_VIEWPORT_RATIO,
2814+
);
2815+
return Math.round(Math.max(
2816+
COMPACT_HISTORY_SLOT_MIN_HEIGHT,
2817+
anchorMax - COMPACT_HISTORY_SLOT_CHROME_RESERVE,
2818+
));
2819+
}, [getCurrentCompactSurfaceWidth]);
2820+
2821+
const getClampedCompactHistorySlotHeight = useCallback((height: number) => (
2822+
Math.round(Math.max(
2823+
COMPACT_HISTORY_SLOT_MIN_HEIGHT,
2824+
Math.min(height, getCompactHistorySlotMaxHeight()),
2825+
))
2826+
), [getCompactHistorySlotMaxHeight]);
2827+
2828+
// 用户未拖动过时(slot 为 null),起拖高度取 styles.css 默认公式值(width*1.18 / 63%),
2829+
// 保证拖动第一帧从当前可见高度连续起步、不跳变。
2830+
const getCompactHistoryStartHeight = useCallback(() => {
2831+
// 起拖基准必须是「当前可见高度」(按当前约束 clamp 后)。存量高度可能来自更大屏 / 更宽 surface,
2832+
// 此时面板已被钳到 max、若用 stale 大值做基准,向下拖会出现「先拖一段没反应」的死区。
2833+
if (compactHistorySlotHeight !== null) {
2834+
return getClampedCompactHistorySlotHeight(compactHistorySlotHeight);
2835+
}
2836+
const surfaceWidth = getCurrentCompactSurfaceWidth();
2837+
const base = getCompactHistoryViewportBase();
2838+
return getClampedCompactHistorySlotHeight(Math.round(Math.min(
2839+
surfaceWidth * COMPACT_HISTORY_SLOT_DEFAULT_WIDTH_RATIO,
2840+
base * COMPACT_HISTORY_SLOT_DEFAULT_VIEWPORT_RATIO,
2841+
)));
2842+
}, [compactHistorySlotHeight, getClampedCompactHistorySlotHeight, getCurrentCompactSurfaceWidth]);
2843+
2844+
const applyCompactHistorySlotHeightVar = useCallback((height: number | null) => {
2845+
if (typeof document === 'undefined') return;
2846+
if (height === null) {
2847+
document.documentElement.style.removeProperty('--compact-history-slot-height');
2848+
} else {
2849+
document.documentElement.style.setProperty(
2850+
'--compact-history-slot-height',
2851+
`${getClampedCompactHistorySlotHeight(height)}px`,
2852+
);
2853+
}
2854+
// CSS 变量变更不会自己通知宿主;让宿主重算 history 命中 rect / Electron 窗口 bounds / 鼠标穿透区。
2855+
if (typeof window !== 'undefined') {
2856+
window.dispatchEvent(new CustomEvent('neko:compact-interaction-geometry-refresh'));
2857+
}
2858+
}, [getClampedCompactHistorySlotHeight]);
2859+
2860+
const finishCompactHistoryResize = useCallback((event?: ReactPointerEvent<HTMLDivElement>) => {
2861+
const resizeState = compactHistoryResizeStateRef.current;
2862+
if (!resizeState) return;
2863+
if (event && resizeState.pointerId !== event.pointerId) return;
2864+
// 只在真正拖动过才落库:纯点击不该把响应式默认高度锁成固定像素值(否则之后视口/宽度变化不再响应)。
2865+
if (resizeState.moved) {
2866+
persistCompactHistorySlotHeight(resizeState.lastHeight);
2867+
setCompactHistorySlotHeight(resizeState.lastHeight);
2868+
}
2869+
const captureTarget = resizeState.captureTarget;
2870+
if (captureTarget && typeof captureTarget.releasePointerCapture === 'function') {
2871+
try {
2872+
if (captureTarget.hasPointerCapture?.(resizeState.pointerId)) {
2873+
captureTarget.releasePointerCapture(resizeState.pointerId);
2874+
}
2875+
} catch (_) {}
2876+
}
2877+
compactHistoryResizeStateRef.current = null;
2878+
setCompactHistoryResizeActive(false);
2879+
}, []);
2880+
2881+
const handleCompactHistoryResizePointerDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
2882+
if (!isCompactSurface) return;
2883+
if (event.pointerType === 'mouse' && event.button !== 0) return;
2884+
event.preventDefault();
2885+
event.stopPropagation();
2886+
const startHeight = getCompactHistoryStartHeight();
2887+
compactHistoryResizeStateRef.current = {
2888+
pointerId: event.pointerId,
2889+
startPointerY: getCompactHistoryResizePointerY(event),
2890+
startHeight,
2891+
lastHeight: getClampedCompactHistorySlotHeight(startHeight),
2892+
moved: false,
2893+
captureTarget: event.currentTarget,
2894+
};
2895+
setCompactHistoryResizeActive(true);
2896+
try {
2897+
event.currentTarget.setPointerCapture?.(event.pointerId);
2898+
} catch (_) {}
2899+
}, [getClampedCompactHistorySlotHeight, getCompactHistoryStartHeight, isCompactSurface]);
2900+
2901+
const handleCompactHistoryResizePointerMove = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
2902+
const resizeState = compactHistoryResizeStateRef.current;
2903+
if (!resizeState || resizeState.pointerId !== event.pointerId) return;
2904+
event.preventDefault();
2905+
event.stopPropagation();
2906+
// bar 在堆砌区顶部:上拖(deltaY < 0)增高,下拖减高。
2907+
const deltaY = getCompactHistoryResizePointerY(event) - resizeState.startPointerY;
2908+
if (deltaY !== 0) resizeState.moved = true;
2909+
const nextHeight = getClampedCompactHistorySlotHeight(resizeState.startHeight - deltaY);
2910+
resizeState.lastHeight = nextHeight;
2911+
setCompactHistorySlotHeight(nextHeight);
2912+
applyCompactHistorySlotHeightVar(nextHeight);
2913+
}, [applyCompactHistorySlotHeightVar, getClampedCompactHistorySlotHeight]);
2914+
2915+
const handleCompactHistoryResizePointerUp = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
2916+
event.preventDefault();
2917+
event.stopPropagation();
2918+
finishCompactHistoryResize(event);
2919+
}, [finishCompactHistoryResize]);
2920+
2921+
const handleCompactHistoryResizePointerCancel = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
2922+
event.preventDefault();
2923+
event.stopPropagation();
2924+
finishCompactHistoryResize(event);
2925+
}, [finishCompactHistoryResize]);
2926+
2927+
// 把已存/恢复的高度写进 CSS 变量(覆盖默认公式);slot 为 null 时清掉、回落默认。
2928+
useEffect(() => {
2929+
if (!isCompactSurface) return;
2930+
applyCompactHistorySlotHeightVar(compactHistorySlotHeight);
2931+
}, [applyCompactHistorySlotHeightVar, compactHistorySlotHeight, isCompactSurface]);
2932+
2933+
// 视口 / 工作区 / compact surface 宽度变化后,按新约束重写 CSS 变量(用新 max clamp 显示高度)。
2934+
// 刻意不改 state、不覆盖 storage:存量 raw 值保留,换屏 / 改宽再放大时能恢复;起拖死区另由
2935+
// getCompactHistoryStartHeight 对基准 clamp 解决。
2936+
useEffect(() => {
2937+
if (!isCompactSurface) return undefined;
2938+
const reapplySlotHeight = () => applyCompactHistorySlotHeightVar(compactHistorySlotHeight);
2939+
window.addEventListener('resize', reapplySlotHeight);
2940+
window.addEventListener('neko:desktop-compact-layout-change', reapplySlotHeight);
2941+
window.addEventListener('neko:compact-surface-resize-width-change', reapplySlotHeight);
2942+
return () => {
2943+
window.removeEventListener('resize', reapplySlotHeight);
2944+
window.removeEventListener('neko:desktop-compact-layout-change', reapplySlotHeight);
2945+
window.removeEventListener('neko:compact-surface-resize-width-change', reapplySlotHeight);
2946+
};
2947+
}, [applyCompactHistorySlotHeightVar, compactHistorySlotHeight, isCompactSurface]);
2948+
27352949
useEffect(() => {
27362950
if (!isCompactSurface || compactSurfaceEffectiveWidth === null) {
27372951
applyCompactSurfaceResizeWidthVar(null);
@@ -5212,6 +5426,11 @@ export default function App({
52125426
isDropTargetAt={isCompactHistoryDropTargetAt}
52135427
onDropToTarget={handleCompactHistoryDropToAvatar}
52145428
onDragStateChange={onCompactHistoryDragStateChange}
5429+
historyResizeActive={compactHistoryResizeActive}
5430+
onHistoryResizePointerDown={handleCompactHistoryResizePointerDown}
5431+
onHistoryResizePointerMove={handleCompactHistoryResizePointerMove}
5432+
onHistoryResizePointerUp={handleCompactHistoryResizePointerUp}
5433+
onHistoryResizePointerCancel={handleCompactHistoryResizePointerCancel}
52155434
/>
52165435
) : null;
52175436
const compactExportHistoryNode = compactExportHistoryElement;

frontend/react-neko-chat/src/CompactExportHistoryPanel.test.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,32 @@ function renderPanel(overrides: Partial<Parameters<typeof CompactExportHistoryPa
4545
}
4646

4747
describe('CompactExportHistoryPanel', () => {
48+
it('shows the history height resize bar only outside preview and wires its hit-region', () => {
49+
const { container, rerender } = renderPanel({ previewOpen: false, visibilityState: 'open' });
50+
const bar = container.querySelector('.compact-export-history-resize-bar');
51+
expect(bar).not.toBeNull();
52+
expect(bar?.getAttribute('data-compact-hit-region-id')).toBe('history:resize');
53+
expect(bar?.getAttribute('data-compact-hit-region-kind')).toBe('resize');
54+
expect(bar?.getAttribute('data-compact-no-drag')).toBe('true');
55+
56+
rerender(<CompactExportHistoryPanel {...createPanelProps({ previewOpen: true, visibilityState: 'open' })} />);
57+
expect(container.querySelector('.compact-export-history-resize-bar')).toBeNull();
58+
});
59+
60+
it('marks the history resize bar active while dragging', () => {
61+
const { container } = renderPanel({ previewOpen: false, visibilityState: 'open', historyResizeActive: true });
62+
expect(container.querySelector('.compact-export-history-resize-bar.is-active')).not.toBeNull();
63+
});
64+
65+
it('drops the history resize bar hit-region when a choice prompt sits above', () => {
66+
const { container } = renderPanel({ previewOpen: false, visibilityState: 'open', choiceLayerAbove: true });
67+
const bar = container.querySelector('.compact-export-history-resize-bar');
68+
expect(bar).not.toBeNull();
69+
expect(bar?.getAttribute('data-compact-hit-region-id')).toBeNull();
70+
expect(bar?.getAttribute('data-compact-hit-region')).toBeNull();
71+
expect(bar?.getAttribute('data-compact-hit-region-kind')).toBeNull();
72+
});
73+
4874
it('pins the history list to bottom when returning from preview', () => {
4975
const scrollTopValues: number[] = [];
5076
const scrollTopByElement = new WeakMap<HTMLElement, number>();

frontend/react-neko-chat/src/CompactExportHistoryPanel.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ type CompactExportHistoryPanelProps = {
9090
isDropTargetAt?: (point: CompactHistoryDropPoint) => boolean;
9191
onDropToTarget?: (request: CompactHistoryDropRequest) => Promise<boolean | void> | boolean | void;
9292
onDragStateChange?: (state: CompactHistoryDragStatePayload) => void;
93+
historyResizeActive?: boolean;
94+
onHistoryResizePointerDown?: (event: ReactPointerEvent<HTMLDivElement>) => void;
95+
onHistoryResizePointerMove?: (event: ReactPointerEvent<HTMLDivElement>) => void;
96+
onHistoryResizePointerUp?: (event: ReactPointerEvent<HTMLDivElement>) => void;
97+
onHistoryResizePointerCancel?: (event: ReactPointerEvent<HTMLDivElement>) => void;
9398
};
9499

95100
export type CompactHistoryDragType = 'image' | 'bubble';
@@ -1225,6 +1230,11 @@ export default function CompactExportHistoryPanel({
12251230
isDropTargetAt,
12261231
onDropToTarget,
12271232
onDragStateChange,
1233+
historyResizeActive,
1234+
onHistoryResizePointerDown,
1235+
onHistoryResizePointerMove,
1236+
onHistoryResizePointerUp,
1237+
onHistoryResizePointerCancel,
12281238
}: CompactExportHistoryPanelProps) {
12291239
const scrollRef = useRef<HTMLDivElement | null>(null);
12301240
const scrollbarDragRef = useRef<ScrollbarDragState | null>(null);
@@ -2498,6 +2508,21 @@ export default function CompactExportHistoryPanel({
24982508
<div className="compact-export-history-panel">
24992509
{previewOpen ? previewNode : (
25002510
<>
2511+
{/* 堆砌区顶部的高度 resize bar:平时透明,hover / 拖动中半透明显现(is-active)。
2512+
放在 scroll 之前,命中区随 anchor 的 children hit-scope 上报给宿主、Electron 下可点不穿透。 */}
2513+
<div
2514+
className={clsx('compact-export-history-resize-bar', { 'is-active': historyResizeActive })}
2515+
data-compact-hit-region={historyInteractive && !choiceLayerAbove ? 'true' : undefined}
2516+
data-compact-hit-region-id={historyInteractive && !choiceLayerAbove ? 'history:resize' : undefined}
2517+
data-compact-hit-region-kind={historyInteractive && !choiceLayerAbove ? 'resize' : undefined}
2518+
data-compact-no-drag="true"
2519+
aria-hidden="true"
2520+
onPointerDown={onHistoryResizePointerDown}
2521+
onPointerMove={onHistoryResizePointerMove}
2522+
onPointerUp={onHistoryResizePointerUp}
2523+
onPointerCancel={onHistoryResizePointerCancel}
2524+
onLostPointerCapture={onHistoryResizePointerCancel}
2525+
/>
25012526
<div
25022527
ref={scrollRef}
25032528
className="compact-export-history-scroll"

0 commit comments

Comments
 (0)