diff --git a/cypress/e2e/sidebar-resize.cy.ts b/cypress/e2e/sidebar-resize.cy.ts new file mode 100644 index 000000000..f8dc3b56a --- /dev/null +++ b/cypress/e2e/sidebar-resize.cy.ts @@ -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); + }); + }); + }); +}); diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index 5431cb7d9..1c14eb6be 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,9 +1,20 @@ -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 @@ -11,44 +22,159 @@ type AppLayoutInternalProps = { map: React.ReactElement bottom?: React.ReactElement modals?: React.ReactNode -} & WithTranslation; +}; -class AppLayoutInternal extends React.Component { +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 -
- {this.props.toolbar} -
- {this.props.codeEditor &&
+ const [sidebarWidth, setSidebarWidth] = useState( + () => getSavedSidebarWidth() ?? DEFAULT_SIDEBAR_WIDTH + ); + const [listRatio, setListRatio] = useState( + () => 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 +
+ {props.toolbar} +
+ {props.codeEditor &&
+ + {props.codeEditor} + +
+ } + {!props.codeEditor && <> +
+ {props.layerList} +
+ - {this.props.bottom &&
- {this.props.bottom} -
- } - {this.props.modals} + - ; - } + {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)} /> { + let store: Record = {}; + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); +vi.stubGlobal("localStorage", localStorageMock); + +describe("sidebar helpers", () => { + describe("clampSidebarWidth", () => { + it("returns MIN when value is below minimum", () => { + expect(clampSidebarWidth(100)).toBe(MIN_SIDEBAR_WIDTH); + }); + + it("returns MAX when value exceeds maximum", () => { + expect(clampSidebarWidth(1200)).toBe(MAX_SIDEBAR_WIDTH); + }); + + it("returns the value when within range", () => { + expect(clampSidebarWidth(500)).toBe(500); + }); + + it("returns exactly MIN at boundary", () => { + expect(clampSidebarWidth(MIN_SIDEBAR_WIDTH)).toBe(MIN_SIDEBAR_WIDTH); + }); + + it("returns exactly MAX at boundary", () => { + expect(clampSidebarWidth(MAX_SIDEBAR_WIDTH)).toBe(MAX_SIDEBAR_WIDTH); + }); + }); + + describe("getSavedSidebarWidth", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns null when nothing is stored", () => { + expect(getSavedSidebarWidth()).toBeNull(); + }); + + it("returns the stored width when valid", () => { + localStorage.setItem("maputnik:sidebarWidth", "600"); + expect(getSavedSidebarWidth()).toBe(600); + }); + + it("returns null when stored value is below MIN", () => { + localStorage.setItem("maputnik:sidebarWidth", "100"); + expect(getSavedSidebarWidth()).toBeNull(); + }); + + it("returns null when stored value is above MAX", () => { + localStorage.setItem("maputnik:sidebarWidth", "9999"); + expect(getSavedSidebarWidth()).toBeNull(); + }); + + it("returns null when stored value is not a number", () => { + localStorage.setItem("maputnik:sidebarWidth", "abc"); + expect(getSavedSidebarWidth()).toBeNull(); + }); + }); + + describe("saveSidebarWidth", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("persists the width to localStorage", () => { + saveSidebarWidth(500); + expect(localStorage.getItem("maputnik:sidebarWidth")).toBe("500"); + }); + }); + + describe("DEFAULT_SIDEBAR_WIDTH", () => { + it("is the sum of list and drawer widths", () => { + expect(DEFAULT_SIDEBAR_WIDTH).toBe(570); + }); + }); + + describe("DEFAULT_LIST_RATIO", () => { + it("is list / total", () => { + expect(DEFAULT_LIST_RATIO).toBeCloseTo(200 / 570, 5); + }); + }); + + describe("getSavedListRatio", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("returns null when nothing is stored", () => { + expect(getSavedListRatio()).toBeNull(); + }); + + it("returns the stored ratio when valid", () => { + localStorage.setItem("maputnik:listRatio", "0.4"); + expect(getSavedListRatio()).toBeCloseTo(0.4); + }); + + it("returns null when stored value is below 0.1", () => { + localStorage.setItem("maputnik:listRatio", "0.05"); + expect(getSavedListRatio()).toBeNull(); + }); + + it("returns null when stored value is above 0.9", () => { + localStorage.setItem("maputnik:listRatio", "0.95"); + expect(getSavedListRatio()).toBeNull(); + }); + + it("returns null when stored value is not a number", () => { + localStorage.setItem("maputnik:listRatio", "abc"); + expect(getSavedListRatio()).toBeNull(); + }); + }); + + describe("saveListRatio", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("persists the ratio to localStorage", () => { + saveListRatio(0.35); + expect(localStorage.getItem("maputnik:listRatio")).toBe("0.35"); + }); + }); +}); diff --git a/src/libs/sidebar.ts b/src/libs/sidebar.ts new file mode 100644 index 000000000..b1f49f059 --- /dev/null +++ b/src/libs/sidebar.ts @@ -0,0 +1,73 @@ +/** + * Sidebar resize helpers. + * + * Extracted from AppLayout so they can be tested independently and + * reused if needed elsewhere. + */ + +export const DEFAULT_LIST_WIDTH = 200; +export const DEFAULT_DRAWER_WIDTH = 370; +export const DEFAULT_SIDEBAR_WIDTH = DEFAULT_LIST_WIDTH + DEFAULT_DRAWER_WIDTH; +export const MIN_SIDEBAR_WIDTH = 280; +export const MAX_SIDEBAR_WIDTH = 800; +export const MIN_LIST_WIDTH = 100; +export const MIN_DRAWER_WIDTH = 150; +export const DEFAULT_LIST_RATIO = DEFAULT_LIST_WIDTH / DEFAULT_SIDEBAR_WIDTH; + +const STORAGE_KEY = "maputnik:sidebarWidth"; +const RATIO_STORAGE_KEY = "maputnik:listRatio"; + +/** Read the persisted sidebar width from localStorage (if valid). */ +export function getSavedSidebarWidth(): number | null { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const val = parseInt(saved, 10); + if (!isNaN(val) && val >= MIN_SIDEBAR_WIDTH && val <= MAX_SIDEBAR_WIDTH) { + return val; + } + } + } catch { + // localStorage may be unavailable + } + return null; +} + +/** Persist the sidebar width to localStorage. */ +export function saveSidebarWidth(width: number): void { + try { + localStorage.setItem(STORAGE_KEY, String(width)); + } catch { + // ignore + } +} + +/** Read the persisted list/drawer ratio from localStorage (if valid). */ +export function getSavedListRatio(): number | null { + try { + const saved = localStorage.getItem(RATIO_STORAGE_KEY); + if (saved) { + const val = parseFloat(saved); + if (!isNaN(val) && val >= 0.1 && val <= 0.9) { + return val; + } + } + } catch { + // localStorage may be unavailable + } + return null; +} + +/** Persist the list/drawer ratio to localStorage. */ +export function saveListRatio(ratio: number): void { + try { + localStorage.setItem(RATIO_STORAGE_KEY, String(ratio)); + } catch { + // ignore + } +} + +/** Clamp a width value to the allowed sidebar range. */ +export function clampSidebarWidth(width: number): number { + return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, width)); +} diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 5545b7023..06bdb3a48 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -54,6 +54,8 @@ "Delete expression": "Ausdruck löschen", "Delete filter block": "Filterblock löschen", "Deuteranopia filter": "Deuteranopie-Filter", + "Drag to resize list / editor split": "__STRING_NOT_TRANSLATED__", + "Drag to resize sidebar": "__STRING_NOT_TRANSLATED__", "Duplicate": "Duplizieren", "Encoding": "Kodierung", "Enter URL...": "URL eingeben...", diff --git a/src/locales/fr/translation.json b/src/locales/fr/translation.json index 2dab1b4aa..96af64dcf 100644 --- a/src/locales/fr/translation.json +++ b/src/locales/fr/translation.json @@ -53,8 +53,8 @@ "Delete": "Supprimer", "Delete expression": "Supprimer l'expression", "Delete filter block": "Supprimer le bloc de filtre", - "Deuteranopia filter": "Filtre Deutéranopie", - "Duplicate": "Dupliquer", + "Deuteranopia filter": "Filtre Deutéranopie", "Drag to resize list / editor split": "__STRING_NOT_TRANSLATED__", + "Drag to resize sidebar": "__STRING_NOT_TRANSLATED__", "Duplicate": "Dupliquer", "Encoding": "Encodage", "Enter URL...": "Entrez l'URL...", "every filter matches": "tous les filtres correspondent", diff --git a/src/locales/he/translation.json b/src/locales/he/translation.json index af7fa8a04..55b4cd4b0 100644 --- a/src/locales/he/translation.json +++ b/src/locales/he/translation.json @@ -54,6 +54,8 @@ "Delete expression": "מחיקת ביטוי", "Delete filter block": "מחיקת גוש מסנן", "Deuteranopia filter": "Deuteranopia filter", + "Drag to resize list / editor split": "__STRING_NOT_TRANSLATED__", + "Drag to resize sidebar": "__STRING_NOT_TRANSLATED__", "Duplicate": "שכפול", "Encoding": "קידוד", "Enter URL...": "הכנסו כתובת", diff --git a/src/locales/it/translation.json b/src/locales/it/translation.json index 073cf0555..9231f950f 100644 --- a/src/locales/it/translation.json +++ b/src/locales/it/translation.json @@ -54,6 +54,8 @@ "Delete expression": "Elimina espressione", "Delete filter block": "Elimina blocco filtro", "Deuteranopia filter": "Filtro deuteranopia", + "Drag to resize list / editor split": "__STRING_NOT_TRANSLATED__", + "Drag to resize sidebar": "__STRING_NOT_TRANSLATED__", "Duplicate": "Duplica", "Encoding": "Codifica", "Enter URL...": "Inserisci URL...", diff --git a/src/locales/ja/translation.json b/src/locales/ja/translation.json index edeebeaf9..6a077c2b9 100644 --- a/src/locales/ja/translation.json +++ b/src/locales/ja/translation.json @@ -54,6 +54,8 @@ "Delete expression": "式を削除", "Delete filter block": "フィルタブロックを削除", "Deuteranopia filter": "緑色盲フィルタ", + "Drag to resize list / editor split": "__STRING_NOT_TRANSLATED__", + "Drag to resize sidebar": "__STRING_NOT_TRANSLATED__", "Duplicate": "複製", "Encoding": "エンコーディング", "Enter URL...": "URLを入力", diff --git a/src/locales/ko/translation.json b/src/locales/ko/translation.json index fb4148287..ed2bd8088 100644 --- a/src/locales/ko/translation.json +++ b/src/locales/ko/translation.json @@ -54,6 +54,8 @@ "Delete expression": "표현식 삭제", "Delete filter block": "필터 블록 삭제", "Deuteranopia filter": "녹색맹 필터", + "Drag to resize list / editor split": "__STRING_NOT_TRANSLATED__", + "Drag to resize sidebar": "__STRING_NOT_TRANSLATED__", "Duplicate": "복제", "Encoding": "인코딩", "Enter URL...": "URL 입력...", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 804982588..c0dc557da 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -54,6 +54,8 @@ "Delete expression": "删除表达式", "Delete filter block": "删除过滤器块", "Deuteranopia filter": "绿色盲滤镜", + "Drag to resize list / editor split": "__STRING_NOT_TRANSLATED__", + "Drag to resize sidebar": "__STRING_NOT_TRANSLATED__", "Duplicate": "复制", "Encoding": "编码", "Enter URL...": "输入URL...", diff --git a/src/styles/_components.scss b/src/styles/_components.scss index 9ef3c829e..8438f4666 100644 --- a/src/styles/_components.scss +++ b/src/styles/_components.scss @@ -6,7 +6,8 @@ .maputnik-map__container { background: white; display: flex; - width: vars.$layout-map-width; + flex: 1; + min-width: 0; &--error { align-items: center; diff --git a/src/styles/_layout.scss b/src/styles/_layout.scss index cfc5109ac..b967935bc 100644 --- a/src/styles/_layout.scss +++ b/src/styles/_layout.scss @@ -13,6 +13,11 @@ //APP LAYOUT .maputnik-layout { + // Default CSS custom properties (overridden inline by AppLayout component) + --sidebar-list-width: #{vars.$layout-list-width}; + --sidebar-drawer-width: #{vars.$layout-editor-width}; + --sidebar-total-width: #{vars.$layout-list-width + vars.$layout-editor-width}; + font-family: vars.$font-family; color: vars.$color-white; @@ -29,19 +34,59 @@ } &-list { - width: 200px; + width: var(--sidebar-list-width); + min-width: 0; + flex-shrink: 0; background-color: vars.$color-black; } &-drawer { - width: 370px; + width: var(--sidebar-drawer-width); + min-width: 0; + flex-shrink: 0; background-color: vars.$color-black; // scroll-container is position: absolute position: relative; } + &-resize-handle { + width: 5px; + flex-shrink: 0; + cursor: col-resize; + background-color: transparent; + position: relative; + z-index: 5; + transition: background-color 0.15s ease; + + &:hover, + &:active { + background-color: rgba(vars.$color-lowgray, 0.5); + } + + &::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 3px; + height: 30px; + border-radius: 2px; + background-color: vars.$color-lowgray; + opacity: 0; + transition: opacity 0.15s ease; + } + + &:hover::after, + &:active::after { + opacity: 0.7; + } + } + &-code-editor { - width: 570px; + width: var(--sidebar-total-width); + min-width: 0; + flex-shrink: 0; background-color: vars.$color-black; // scroll-container is position: absolute position: relative; @@ -52,7 +97,7 @@ bottom: 0; right: 0; z-index: 10; - width: vars.$layout-map-width; + width: calc(100% - var(--sidebar-total-width)); background-color: vars.$color-black; } }