refactor(splitter): make splitter thinner and totally draggable

It was mentioned that the splitter was too thick and therefore
taking up too much screen-space. This commit changes it to be thinner.
While the visual representation is just a thin line, the actual trigger
zone is wider to make it not a perfect-pixel-matching game.
Furthermore, you can now drag the splitter on every point of it, not
just on the button.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-07-17 01:31:16 +02:00
committed by Philip Molares
parent da9f1d9b8e
commit 1dc4fea414
5 changed files with 152 additions and 41 deletions
@@ -17,9 +17,15 @@ exports[`Splitter resize can change size with mouse 1`] = `
</div>
</div>
<div
aria-orientation="vertical"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow="50"
class="divider"
data-testid="splitter-divider"
id="editor-splitter"
role="separator"
tabindex="0"
>
<div
class="middle"
@@ -34,10 +40,7 @@ exports[`Splitter resize can change size with mouse 1`] = `
BootstrapIconMock_ArrowLeft
</button>
<span
aria-orientation="vertical"
class="grabber"
role="separator"
tabindex="0"
>
BootstrapIconMock_ArrowLeftRight
</span>
@@ -82,9 +85,15 @@ exports[`Splitter resize can change size with touch: touch initial 1`] = `
</div>
</div>
<div
aria-orientation="vertical"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow="50"
class="divider"
data-testid="splitter-divider"
id="editor-splitter"
role="separator"
tabindex="0"
>
<div
class="middle"
@@ -99,10 +108,7 @@ exports[`Splitter resize can change size with touch: touch initial 1`] = `
BootstrapIconMock_ArrowLeft
</button>
<span
aria-orientation="vertical"
class="grabber"
role="separator"
tabindex="0"
>
BootstrapIconMock_ArrowLeftRight
</span>
@@ -147,9 +153,15 @@ exports[`Splitter resize can change size with touch: touch move to left 1`] = `
</div>
</div>
<div
aria-orientation="vertical"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow="50"
class="divider"
data-testid="splitter-divider"
id="editor-splitter"
role="separator"
tabindex="0"
>
<div
class="middle"
@@ -164,10 +176,7 @@ exports[`Splitter resize can change size with touch: touch move to left 1`] = `
BootstrapIconMock_ArrowLeft
</button>
<span
aria-orientation="vertical"
class="grabber"
role="separator"
tabindex="0"
>
BootstrapIconMock_ArrowLeftRight
</span>
@@ -212,9 +221,15 @@ exports[`Splitter resize can change size with touch: touch move to middle 1`] =
</div>
</div>
<div
aria-orientation="vertical"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow="50"
class="divider"
data-testid="splitter-divider"
id="editor-splitter"
role="separator"
tabindex="0"
>
<div
class="middle"
@@ -229,10 +244,7 @@ exports[`Splitter resize can change size with touch: touch move to middle 1`] =
BootstrapIconMock_ArrowLeft
</button>
<span
aria-orientation="vertical"
class="grabber"
role="separator"
tabindex="0"
>
BootstrapIconMock_ArrowLeftRight
</span>
@@ -277,9 +289,15 @@ exports[`Splitter resize can change size with touch: touch move to right 1`] = `
</div>
</div>
<div
aria-orientation="vertical"
aria-valuemax="100"
aria-valuemin="0"
aria-valuenow="50"
class="divider"
data-testid="splitter-divider"
id="editor-splitter"
role="separator"
tabindex="0"
>
<div
class="middle"
@@ -294,10 +312,7 @@ exports[`Splitter resize can change size with touch: touch move to right 1`] = `
BootstrapIconMock_ArrowLeft
</button>
<span
aria-orientation="vertical"
class="grabber"
role="separator"
tabindex="0"
>
BootstrapIconMock_ArrowLeftRight
</span>
@@ -1,22 +1,46 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
* SPDX-FileCopyrightText: 2026 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.divider {
width: 15px;
background: var(--bs-body-bg);
width: 5px;
// This ensures that the handle area is thicker but the visual representation is still thin
background: linear-gradient(
to right,
transparent 0,
transparent 2px,
var(--bs-border-color) 2px,
var(--bs-border-color) 3px,
transparent 3px
);
z-index: 1;
box-shadow: 0 0 6px var(--bs-gray-400);
cursor: col-resize;
position: relative;
display: flex;
align-items: center;
justify-content: center;
&::before {
content: '';
position: absolute;
inset: 0 -5px;
}
:global(.btn) {
cursor: default;
}
}
.grabber {
cursor: col-resize;
user-select: none;
> * {
pointer-events: none;
}
}
.middle {
@@ -7,7 +7,8 @@ import { concatCssClasses } from '../../../../utils/concat-css-classes'
import { testId } from '../../../../utils/test-id'
import { UiIcon } from '../../../common/icons/ui-icon'
import styles from './split-divider.module.scss'
import React, { useMemo } from 'react'
import type { MouseEvent, TouchEvent } from 'react'
import React, { useCallback, useMemo } from 'react'
import { Button } from 'react-bootstrap'
import {
ArrowLeft as IconArrowLeft,
@@ -28,6 +29,7 @@ export interface SplitDividerProps {
forceOpen: boolean
focusLeft: boolean
focusRight: boolean
splitValue: number
dividerButtonsShift: DividerButtonsShift
}
@@ -42,6 +44,7 @@ export interface SplitDividerProps {
* @param focusLeft defines if the left button should be focused
* @param focusRight defines if the right button should be focused
* @param forceOpen defines if the arrow buttons should always be visible
* @param splitValue the currently selected split position in percent
*/
export const SplitDivider: React.FC<SplitDividerProps> = ({
onGrab,
@@ -50,7 +53,8 @@ export const SplitDivider: React.FC<SplitDividerProps> = ({
dividerButtonsShift,
focusLeft,
focusRight,
forceOpen
forceOpen,
splitValue
}) => {
const className = useMemo(() => {
return concatCssClasses(styles.middle, {
@@ -59,24 +63,53 @@ export const SplitDivider: React.FC<SplitDividerProps> = ({
})
}, [dividerButtonsShift, forceOpen])
const stopResizing = useCallback((event: MouseEvent | TouchEvent) => {
event.stopPropagation()
}, [])
const startResizingFromIndicator = useCallback(
(event: MouseEvent | TouchEvent) => {
event.stopPropagation()
onGrab()
},
[onGrab]
)
return (
<div className={styles.divider} {...testId('splitter-divider')} id={'editor-splitter'}>
<div className={className}>
// oxlint-disable-next-line jsx_a11y/no-static-element-interactions
<div
className={styles.divider}
id={'editor-splitter'}
role={'separator'}
aria-orientation={'vertical'}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={splitValue}
tabIndex={0}
{...testId('splitter-divider')}
onMouseDown={onGrab}
onTouchStart={onGrab}>
{/* oxlint-disable-next-line jsx_a11y/no-static-element-interactions */}
<div className={className} onMouseDown={startResizingFromIndicator} onTouchStart={startResizingFromIndicator}>
<div className={styles.buttons}>
<Button variant={focusLeft ? 'secondary' : 'light'} onClick={onLeftButtonClick}>
<Button
variant={focusLeft ? 'secondary' : 'light'}
onClick={onLeftButtonClick}
onMouseDown={stopResizing}
onTouchStart={stopResizing}>
<UiIcon icon={IconArrowLeft} />
</Button>
{/* oxlint-disable-next-line jsx_a11y/no-static-element-interactions */}
<span
role={'separator'}
aria-orientation={'vertical'}
tabIndex={0}
onMouseDown={onGrab}
onTouchStart={onGrab}
className={styles['grabber']}>
className={styles['grabber']}
onMouseDownCapture={startResizingFromIndicator}
onTouchStartCapture={startResizingFromIndicator}>
<UiIcon icon={IconArrowLeftRight} />
</span>
<Button variant={focusRight ? 'secondary' : 'light'} onClick={onRightButtonClick}>
<Button
variant={focusRight ? 'secondary' : 'light'}
onClick={onRightButtonClick}
onMouseDown={stopResizing}
onTouchStart={stopResizing}>
<UiIcon icon={IconArrowRight} />
</Button>
</div>
@@ -15,6 +15,26 @@ jest.mock('../../../redux/editor-config/methods')
const setEditorSplitPosition = jest.spyOn(EditorConfigModule, 'setEditorSplitPosition').mockReturnValue()
const findMoveOverlay = (container: HTMLElement): Element => {
const overlay = container.querySelector('.move-overlay')
if (!overlay) {
throw new Error('Move overlay was not rendered')
}
return overlay
}
const findGrabber = (container: HTMLElement): Element => {
const grabber = container.querySelector('.grabber')
if (!grabber) {
throw new Error('Grabber was not rendered')
}
return grabber
}
describe('Splitter', () => {
describe('resize', () => {
beforeEach(() => {
@@ -44,15 +64,24 @@ describe('Splitter', () => {
const divider = await screen.findByTestId('splitter-divider')
fireEvent.mouseDown(divider, {})
fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 1920 }))
fireEvent.mouseUp(window)
let moveOverlay = findMoveOverlay(view.container)
fireEvent.mouseMove(moveOverlay, Mock.of<MouseEvent>({ buttons: 1, clientX: 1920 }))
fireEvent.mouseUp(moveOverlay)
expect(setEditorSplitPosition).toHaveBeenCalledWith(100)
fireEvent.mouseDown(divider, {})
fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 0 }))
fireEvent.mouseUp(window)
moveOverlay = findMoveOverlay(view.container)
fireEvent.mouseMove(moveOverlay, Mock.of<MouseEvent>({ buttons: 1, clientX: 0 }))
fireEvent.mouseUp(moveOverlay)
expect(setEditorSplitPosition).toHaveBeenCalledWith(0)
const grabber = findGrabber(view.container)
fireEvent.mouseDown(grabber, {})
moveOverlay = findMoveOverlay(view.container)
fireEvent.mouseMove(moveOverlay, Mock.of<MouseEvent>({ buttons: 1, clientX: 960 }))
fireEvent.mouseUp(moveOverlay)
expect(setEditorSplitPosition).toHaveBeenCalledWith(50)
fireEvent.mouseMove(window, Mock.of<MouseEvent>({ buttons: 1, clientX: 1920 }))
expect(setEditorSplitPosition).toHaveBeenCalledWith(100)
})
@@ -77,8 +106,9 @@ describe('Splitter', () => {
}
fireEvent.touchStart(divider, {})
let moveOverlay = findMoveOverlay(view.container)
fireEvent.touchMove(
window,
moveOverlay,
Mock.of<TouchEvent>({
touches: [
{ ...defaultTouchEvent, clientX: 1920 },
@@ -86,12 +116,14 @@ describe('Splitter', () => {
]
})
)
fireEvent.touchEnd(window)
fireEvent.touchEnd(moveOverlay)
expect(setEditorSplitPosition).toHaveBeenCalledWith(100)
expect(view.container).toMatchSnapshot('touch move to left')
fireEvent.touchStart(divider, {})
moveOverlay = findMoveOverlay(view.container)
fireEvent.touchMove(
window,
moveOverlay,
Mock.of<TouchEvent>({
touches: [
{ ...defaultTouchEvent, clientX: 0 },
@@ -99,11 +131,15 @@ describe('Splitter', () => {
]
})
)
fireEvent.touchCancel(window)
fireEvent.touchCancel(moveOverlay)
expect(setEditorSplitPosition).toHaveBeenCalledWith(0)
expect(view.container).toMatchSnapshot('touch move to right')
const grabber = findGrabber(view.container)
fireEvent.touchStart(grabber, {})
moveOverlay = findMoveOverlay(view.container)
fireEvent.touchMove(
window,
moveOverlay,
Mock.of<TouchEvent>({
touches: [
{ ...defaultTouchEvent, clientX: 500 },
@@ -111,6 +147,8 @@ describe('Splitter', () => {
]
})
)
fireEvent.touchEnd(moveOverlay)
expect(setEditorSplitPosition).toHaveBeenCalledWith(expect.closeTo(26, 1))
expect(view.container).toMatchSnapshot('touch move to middle')
})
})
@@ -169,6 +169,7 @@ export const Splitter: React.FC<SplitterProps> = ({ additionalContainerClassName
forceOpen={resizingInProgress}
focusLeft={relativeSplitValue < SNAP_PERCENTAGE}
focusRight={relativeSplitValue > 100 - SNAP_PERCENTAGE}
splitValue={adjustedRelativeSplitValue}
dividerButtonsShift={dividerButtonsShift}
/>
<div