diff --git a/CHANGELOG.md b/CHANGELOG.md index 325aadb39..0f60630d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - Add support for sprite object in setting modal - Set the correct map view when opening a new style on an empty map - Allow root-relative urls in the stylefile +- Add ability to change the size of the panels - _...Add new stuff here..._ ### 🐞 Bug fixes @@ -29,6 +30,7 @@ - Fixed headers in left panes (Layers list and Layer editor) to remain visible when scrolling - Fix error when using a source from localhost - Fix an issue with scrolling when using the code editor +- Fix Cypress E2E test failures caused by resizable panel sizes persisting in localStorage. - _...Add new stuff here..._ ## 3.0.0 diff --git a/cypress/e2e/maputnik-cypress-helper.ts b/cypress/e2e/maputnik-cypress-helper.ts index 90467828a..d8a5d625f 100644 --- a/cypress/e2e/maputnik-cypress-helper.ts +++ b/cypress/e2e/maputnik-cypress-helper.ts @@ -49,7 +49,46 @@ export default class MaputnikCypressHelper { force: true, }); }, + + pointerDrag: (testId: string, deltaX: number) => { + cy.get(`[data-wd-key="${testId}"]`).then(($el) => { + const rect = $el[0].getBoundingClientRect(); + const startX = rect.left + rect.width / 2; + const startY = rect.top + rect.height / 2; + + cy.document().trigger("pointerdown", { + clientX: startX, + clientY: startY, + pointerId: 1, + button: 0, + buttons: 1, + pointerType: "mouse", + bubbles: true, + }); + cy.document().trigger("pointermove", { + clientX: startX + deltaX, + clientY: startY, + pointerId: 1, + button: 0, + buttons: 1, + pointerType: "mouse", + bubbles: true, + }); + cy.document().trigger("pointerup", { + clientX: startX + deltaX, + clientY: startY, + pointerId: 1, + button: 0, + buttons: 0, + pointerType: "mouse", + bubbles: true, + }); + }); + }, ...this.helper.when, + visit: (url: string, options?: Partial) => { + cy.visit(url, options); + }, }; public beforeAndAfter = this.helper.beforeAndAfter; diff --git a/cypress/e2e/maputnik-driver.ts b/cypress/e2e/maputnik-driver.ts index 692b038db..9698777f4 100644 --- a/cypress/e2e/maputnik-driver.ts +++ b/cypress/e2e/maputnik-driver.ts @@ -29,6 +29,17 @@ export class MaputnikDriver { private helper = new MaputnikCypressHelper(); private modalDriver = new ModalDriver(); + private resetLayoutStorage = (win: Window) => { + const keysToRemove: string[] = []; + for (let i = 0; i < win.localStorage.length; i++) { + const key = win.localStorage.key(i); + if (key && (key.startsWith("react-resizable-panels:maputnik") || key.startsWith("maputnik:sidebar"))) { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => win.localStorage.removeItem(key)); + }; + public beforeAndAfter = () => { beforeEach(() => { this.given.setupMockBackedResponses(); @@ -183,7 +194,11 @@ export class MaputnikDriver { if (zoom) { url.hash = `${zoom}/41.3805/2.1635`; } - this.helper.when.visit(url.toString()); + this.helper.when.visit(url.toString(), { + onBeforeLoad: (win) => { + this.resetLayoutStorage(win); + }, + }); if (styleProperties) { this.helper.when.acceptConfirm(); } diff --git a/cypress/e2e/sidebar-resize.cy.ts b/cypress/e2e/sidebar-resize.cy.ts new file mode 100644 index 000000000..7feedad42 --- /dev/null +++ b/cypress/e2e/sidebar-resize.cy.ts @@ -0,0 +1,44 @@ +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; + + when.pointerDrag("sidebar-resize-handle", 100); + + 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; + + when.pointerDrag("inner-resize-handle", 80); + + get.element(".maputnik-layout-list").should(($listAfter) => { + const newWidth = $listAfter[0].getBoundingClientRect().width; + expect(newWidth).to.be.greaterThan(initialWidth); + }); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index e7c4a97d6..e208cfc9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "react-i18next": "^17.0.7", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.11.0", "reconnecting-websocket": "^4.4.0", "slugify": "^1.6.9", "string-hash": "^1.1.3", @@ -12556,6 +12557,16 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable-panels": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-4.11.0.tgz", + "integrity": "sha512-LPk/AkFDGkg7SsbOyL93ojrE6E7lhrxxDwnYNjfmnSeI6BE7Sje6dB24PXgZk8DeugdeXNk1LO+ohRqIjhxiLw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-router": { "version": "6.30.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", diff --git a/package.json b/package.json index 1f989000a..ff9bd94a5 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "react-i18next": "^17.0.7", "react-icons": "^5.6.0", "react-markdown": "^10.1.0", + "react-resizable-panels": "^4.11.0", "reconnecting-websocket": "^4.4.0", "slugify": "^1.6.9", "string-hash": "^1.1.3", diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 5431cb7d9..980ab28d4 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,9 +1,29 @@ -import React from "react"; +import React, {useEffect, useState} from "react"; +import {Group, Panel, Separator, useDefaultLayout} from "react-resizable-panels"; 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"; -type AppLayoutInternalProps = { +const DEFAULT_LIST_WIDTH = 200; +const DEFAULT_DRAWER_WIDTH = 370; +const DEFAULT_SIDEBAR_WIDTH = DEFAULT_LIST_WIDTH + DEFAULT_DRAWER_WIDTH; +const DEFAULT_LIST_RATIO = DEFAULT_LIST_WIDTH / DEFAULT_SIDEBAR_WIDTH; + +const SIDEBAR_LAYOUT_STORAGE_ID = "maputnik:sidebar-layout"; +const SIDEBAR_INNER_LAYOUT_STORAGE_ID = "maputnik:sidebar-inner-layout"; +const SIDEBAR_PANEL_ID = "sidebar"; +const MAP_PANEL_ID = "map"; +const LIST_PANEL_ID = "list"; +const DRAWER_PANEL_ID = "drawer"; + +const getDefaultSidebarPercent = () => { + const viewportWidth = window.innerWidth || DEFAULT_SIDEBAR_WIDTH; + return Math.min(100, (DEFAULT_SIDEBAR_WIDTH / viewportWidth) * 100); +}; + +const getDefaultListPercent = () => DEFAULT_LIST_RATIO * 100; + +type AppLayoutProps = { toolbar: React.ReactElement layerList: React.ReactElement layerEditor?: React.ReactElement @@ -11,44 +31,114 @@ type AppLayoutInternalProps = { map: React.ReactElement bottom?: React.ReactElement modals?: React.ReactNode -} & WithTranslation; - -class AppLayoutInternal extends React.Component { - - render() { - document.body.dir = this.props.i18n.dir(); - - return -
- {this.props.toolbar} -
- {this.props.codeEditor &&
- - {this.props.codeEditor} - -
- } - {!this.props.codeEditor && <> -
- {this.props.layerList} -
-
- - {this.props.layerEditor} - -
- } - {this.props.map} -
- {this.props.bottom &&
- {this.props.bottom} -
- } - {this.props.modals} +}; + +export default function AppLayout(props: AppLayoutProps) { + const {t, i18n} = useTranslation(); + + const sidebarLayout = useDefaultLayout({ + id: SIDEBAR_LAYOUT_STORAGE_ID, + panelIds: [SIDEBAR_PANEL_ID, MAP_PANEL_ID], + }); + const sidebarInnerLayout = useDefaultLayout({ + id: SIDEBAR_INNER_LAYOUT_STORAGE_ID, + panelIds: [LIST_PANEL_ID, DRAWER_PANEL_ID], + }); + + useEffect(() => { + document.body.dir = i18n.dir(); + }, [i18n]); + + const [sidebarSize, setSidebarSize] = useState( + () => sidebarLayout.defaultLayout?.[SIDEBAR_PANEL_ID] ?? getDefaultSidebarPercent() + ); + const [listSize, setListSize] = useState( + () => sidebarInnerLayout.defaultLayout?.[LIST_PANEL_ID] ?? getDefaultListPercent() + ); + + return +
+ {props.toolbar} +
+ { + setSidebarSize(layout[SIDEBAR_PANEL_ID] ?? sidebarSize); + sidebarLayout.onLayoutChanged(layout); + }} + > + + {props.codeEditor ? + {props.codeEditor} + : { + setListSize(layout[LIST_PANEL_ID] ?? listSize); + sidebarInnerLayout.onLayoutChanged(layout); + }} + > + + {props.layerList} + + + + + {props.layerEditor} + + + } + + + + {props.map} + +
- ; - } + {props.bottom &&
+ {props.bottom} +
+ } + {props.modals} +
+
; } - -const AppLayout = withTranslation()(AppLayoutInternal); -export default AppLayout; diff --git a/src/components/LayerListItem.tsx b/src/components/LayerListItem.tsx index 266b5982a..c30f37652 100644 --- a/src/components/LayerListItem.tsx +++ b/src/components/LayerListItem.tsx @@ -14,11 +14,19 @@ type DraggableLabelProps = { layerType: string dragAttributes?: React.HTMLAttributes dragListeners?: React.HTMLAttributes + onSelect: () => void }; const DraggableLabel: React.FC = (props) => { const { dragAttributes, dragListeners } = props; - return
+ + const handleClick = (e: React.MouseEvent) => { + // Ensure layer selection fires even when dnd-kit captures the pointer + e.stopPropagation(); + props.onSelect(); + }; + + return
((props layerType={props.layerType} dragAttributes={attributes} dragListeners={listeners} + onSelect={() => props.onLayerSelect(props.layerIndex)} />