Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions cypress/e2e/sidebar-resize.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { MaputnikDriver } from "./maputnik-driver";

describe("sidebar resize", () => {
const { beforeAndAfter, get, when, then } = new MaputnikDriver();
beforeAndAfter();

beforeEach(() => {
when.setStyle("both");
});

it("resize handle is visible", () => {
then(get.elementByTestId("sidebar-resize-handle")).shouldBeVisible();
});

it("inner resize handle is visible", () => {
then(get.elementByTestId("inner-resize-handle")).shouldBeVisible();
});

it("dragging the handle changes sidebar width", () => {
get.element(".maputnik-layout-list").then(($list) => {
const initialWidth = $list[0].getBoundingClientRect().width;

get.elementByTestId("sidebar-resize-handle")
.realMouseDown({ position: "center" })
.realMouseMove(100, 0, { position: "center" })
.realMouseUp();

get.element(".maputnik-layout-list").should(($listAfter) => {
const newWidth = $listAfter[0].getBoundingClientRect().width;
expect(newWidth).to.be.greaterThan(initialWidth);
});
});
});

it("dragging inner handle changes list/drawer split", () => {
get.element(".maputnik-layout-list").then(($list) => {
const initialWidth = $list[0].getBoundingClientRect().width;

get.elementByTestId("inner-resize-handle")
.realMouseDown({ position: "center" })
.realMouseMove(80, 0, { position: "center" })
.realMouseUp();

get.element(".maputnik-layout-list").should(($listAfter) => {
const newWidth = $listAfter[0].getBoundingClientRect().width;
expect(newWidth).to.be.greaterThan(initialWidth);
});
});
});
});
200 changes: 163 additions & 37 deletions src/components/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,180 @@
import React from "react";
import React, {useCallback, useEffect, useRef, useState} from "react";
import ScrollContainer from "./ScrollContainer";
import { type WithTranslation, withTranslation } from "react-i18next";
import { IconContext } from "react-icons";
import {useTranslation} from "react-i18next";
import {IconContext} from "react-icons";
import {
DEFAULT_LIST_RATIO,
DEFAULT_SIDEBAR_WIDTH,
MIN_LIST_WIDTH,
MIN_DRAWER_WIDTH,
clampSidebarWidth,
getSavedSidebarWidth,
getSavedListRatio,
saveSidebarWidth,
saveListRatio,
} from "../libs/sidebar";

type AppLayoutInternalProps = {
type AppLayoutProps = {
toolbar: React.ReactElement
layerList: React.ReactElement
layerEditor?: React.ReactElement
codeEditor?: React.ReactElement
map: React.ReactElement
bottom?: React.ReactElement
modals?: React.ReactNode
} & WithTranslation;
};

class AppLayoutInternal extends React.Component<AppLayoutInternalProps> {
export default function AppLayout(props: AppLayoutProps) {
const {t, i18n} = useTranslation();

render() {
document.body.dir = this.props.i18n.dir();
useEffect(() => {
document.body.dir = i18n.dir();
}, [i18n]);

return <IconContext.Provider value={{size: "14px"}}>
<div className="maputnik-layout">
{this.props.toolbar}
<div className="maputnik-layout-main">
{this.props.codeEditor && <div className="maputnik-layout-code-editor">
const [sidebarWidth, setSidebarWidth] = useState<number>(
() => getSavedSidebarWidth() ?? DEFAULT_SIDEBAR_WIDTH
);
const [listRatio, setListRatio] = useState<number>(
() => getSavedListRatio() ?? DEFAULT_LIST_RATIO
);

// Outer handle (sidebar <-> map) drag state
const isDragging = useRef(false);
const startX = useRef(0);
const startWidth = useRef(0);

// Inner handle (list <-> drawer) drag state
const isInnerDragging = useRef(false);
const innerStartX = useRef(0);
const innerStartListWidth = useRef(0);

// Compute sub-widths from ratio
const listWidth = Math.round(sidebarWidth * listRatio);
const drawerWidth = sidebarWidth - listWidth;

const handleMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isDragging.current = true;
startX.current = e.clientX;
startWidth.current = sidebarWidth;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [sidebarWidth]);

// Inner handle: resize list <-> drawer split
const handleInnerMouseDown = useCallback((e: React.MouseEvent) => {
e.preventDefault();
isInnerDragging.current = true;
innerStartX.current = e.clientX;
innerStartListWidth.current = listWidth;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [listWidth]);

useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
const isRtl = document.body.dir === "rtl";

// Outer drag
if (isDragging.current) {
const delta = isRtl
? startX.current - e.clientX
: e.clientX - startX.current;
const newWidth = clampSidebarWidth(startWidth.current + delta);
setSidebarWidth(newWidth);
}

// Inner drag
if (isInnerDragging.current) {
const delta = isRtl
? innerStartX.current - e.clientX
: e.clientX - innerStartX.current;
const newListWidth = innerStartListWidth.current + delta;
setSidebarWidth((sw) => {
const clampedList = Math.max(MIN_LIST_WIDTH, Math.min(sw - MIN_DRAWER_WIDTH, newListWidth));
const newRatio = clampedList / sw;
setListRatio(newRatio);
return sw;
});
}
};

const handleMouseUp = () => {
if (isDragging.current) {
isDragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
setSidebarWidth((w) => {
saveSidebarWidth(w);
return w;
});
}
if (isInnerDragging.current) {
isInnerDragging.current = false;
document.body.style.cursor = "";
document.body.style.userSelect = "";
setListRatio((r) => {
saveListRatio(r);
return r;
});
}
};

document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, []);

return <IconContext.Provider value={{size: "14px"}}>
<div className="maputnik-layout" style={{
"--sidebar-list-width": `${listWidth}px`,
"--sidebar-drawer-width": `${drawerWidth}px`,
"--sidebar-total-width": `${sidebarWidth}px`,
} as React.CSSProperties}>
{props.toolbar}
<div className="maputnik-layout-main">
{props.codeEditor && <div className="maputnik-layout-code-editor">
<ScrollContainer>
{props.codeEditor}
</ScrollContainer>
</div>
}
{!props.codeEditor && <>
<div className="maputnik-layout-list">
{props.layerList}
</div>
<div
className="maputnik-layout-resize-handle maputnik-layout-resize-handle--inner"
data-wd-key="inner-resize-handle"
onMouseDown={handleInnerMouseDown}
title={t("Drag to resize list / editor split")}
tabIndex={-1}
aria-hidden="true"
/>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.codeEditor}
{props.layerEditor}
</ScrollContainer>
</div>
}
{!this.props.codeEditor && <>
<div className="maputnik-layout-list">
{this.props.layerList}
</div>
<div className="maputnik-layout-drawer">
<ScrollContainer>
{this.props.layerEditor}
</ScrollContainer>
</div>
</>}
{this.props.map}
</div>
{this.props.bottom && <div className="maputnik-layout-bottom">
{this.props.bottom}
</div>
}
{this.props.modals}
<div
className="maputnik-layout-resize-handle"
data-wd-key="sidebar-resize-handle"
onMouseDown={handleMouseDown}
title={t("Drag to resize sidebar")}
tabIndex={-1}
aria-hidden="true"
/>
</>}
{props.map}
</div>
</IconContext.Provider>;
}
{props.bottom && <div className="maputnik-layout-bottom">
{props.bottom}
</div>
}
{props.modals}
</div>
</IconContext.Provider>;
}

const AppLayout = withTranslation()(AppLayoutInternal);
export default AppLayout;
11 changes: 10 additions & 1 deletion src/components/LayerListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@ type DraggableLabelProps = {
layerType: string
dragAttributes?: React.HTMLAttributes<HTMLElement>
dragListeners?: React.HTMLAttributes<HTMLElement>
onSelect: () => void
};

const DraggableLabel: React.FC<DraggableLabelProps> = (props) => {
const { dragAttributes, dragListeners } = props;
return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners}>

const handleClick = (e: React.MouseEvent) => {
// Ensure layer selection fires even when dnd-kit captures the pointer
e.stopPropagation();
props.onSelect();
};

return <div className="maputnik-layer-list-item-handle" {...dragAttributes} {...dragListeners} onClick={handleClick}>
<IconLayer
className="layer-handle__icon"
type={props.layerType}
Expand Down Expand Up @@ -138,6 +146,7 @@ const LayerListItem = React.forwardRef<HTMLLIElement, LayerListItemProps>((props
layerType={props.layerType}
dragAttributes={attributes}
dragListeners={listeners}
onSelect={() => props.onLayerSelect(props.layerIndex)}
/>
<span style={{ flexGrow: 1 }} />
<IconAction
Expand Down
Loading