|
1 | | -import React from "react"; |
| 1 | +import React, {useCallback, useEffect, useRef, useState} from "react"; |
2 | 2 | import ScrollContainer from "./ScrollContainer"; |
3 | | -import { type WithTranslation, withTranslation } from "react-i18next"; |
4 | | -import { IconContext } from "react-icons"; |
| 3 | +import {useTranslation} from "react-i18next"; |
| 4 | +import {IconContext} from "react-icons"; |
| 5 | +import { |
| 6 | + DEFAULT_LIST_RATIO, |
| 7 | + DEFAULT_SIDEBAR_WIDTH, |
| 8 | + MIN_LIST_WIDTH, |
| 9 | + MIN_DRAWER_WIDTH, |
| 10 | + clampSidebarWidth, |
| 11 | + getSavedSidebarWidth, |
| 12 | + getSavedListRatio, |
| 13 | + saveSidebarWidth, |
| 14 | + saveListRatio, |
| 15 | +} from "../libs/sidebar"; |
5 | 16 |
|
6 | | -type AppLayoutInternalProps = { |
| 17 | +type AppLayoutProps = { |
7 | 18 | toolbar: React.ReactElement |
8 | 19 | layerList: React.ReactElement |
9 | 20 | layerEditor?: React.ReactElement |
10 | 21 | codeEditor?: React.ReactElement |
11 | 22 | map: React.ReactElement |
12 | 23 | bottom?: React.ReactElement |
13 | 24 | modals?: React.ReactNode |
14 | | -} & WithTranslation; |
| 25 | +}; |
15 | 26 |
|
16 | | -class AppLayoutInternal extends React.Component<AppLayoutInternalProps> { |
| 27 | +export default function AppLayout(props: AppLayoutProps) { |
| 28 | + const {i18n} = useTranslation(); |
17 | 29 |
|
18 | | - render() { |
19 | | - document.body.dir = this.props.i18n.dir(); |
| 30 | + useEffect(() => { |
| 31 | + document.body.dir = i18n.dir(); |
| 32 | + }, [i18n]); |
20 | 33 |
|
21 | | - return <IconContext.Provider value={{size: "14px"}}> |
22 | | - <div className="maputnik-layout"> |
23 | | - {this.props.toolbar} |
24 | | - <div className="maputnik-layout-main"> |
25 | | - {this.props.codeEditor && <div className="maputnik-layout-code-editor"> |
| 34 | + const [sidebarWidth, setSidebarWidth] = useState<number>( |
| 35 | + () => getSavedSidebarWidth() ?? DEFAULT_SIDEBAR_WIDTH |
| 36 | + ); |
| 37 | + const [listRatio, setListRatio] = useState<number>( |
| 38 | + () => getSavedListRatio() ?? DEFAULT_LIST_RATIO |
| 39 | + ); |
| 40 | + |
| 41 | + // Outer handle (sidebar <-> map) drag state |
| 42 | + const isDragging = useRef(false); |
| 43 | + const startX = useRef(0); |
| 44 | + const startWidth = useRef(0); |
| 45 | + |
| 46 | + // Inner handle (list <-> drawer) drag state |
| 47 | + const isInnerDragging = useRef(false); |
| 48 | + const innerStartX = useRef(0); |
| 49 | + const innerStartListWidth = useRef(0); |
| 50 | + |
| 51 | + // Compute sub-widths from ratio |
| 52 | + const listWidth = Math.round(sidebarWidth * listRatio); |
| 53 | + const drawerWidth = sidebarWidth - listWidth; |
| 54 | + |
| 55 | + const handleMouseDown = useCallback((e: React.MouseEvent) => { |
| 56 | + e.preventDefault(); |
| 57 | + isDragging.current = true; |
| 58 | + startX.current = e.clientX; |
| 59 | + startWidth.current = sidebarWidth; |
| 60 | + document.body.style.cursor = "col-resize"; |
| 61 | + document.body.style.userSelect = "none"; |
| 62 | + }, [sidebarWidth]); |
| 63 | + |
| 64 | + // Inner handle: resize list <-> drawer split |
| 65 | + const handleInnerMouseDown = useCallback((e: React.MouseEvent) => { |
| 66 | + e.preventDefault(); |
| 67 | + isInnerDragging.current = true; |
| 68 | + innerStartX.current = e.clientX; |
| 69 | + innerStartListWidth.current = listWidth; |
| 70 | + document.body.style.cursor = "col-resize"; |
| 71 | + document.body.style.userSelect = "none"; |
| 72 | + }, [listWidth]); |
| 73 | + |
| 74 | + useEffect(() => { |
| 75 | + const handleMouseMove = (e: MouseEvent) => { |
| 76 | + const isRtl = document.body.dir === "rtl"; |
| 77 | + |
| 78 | + // Outer drag |
| 79 | + if (isDragging.current) { |
| 80 | + const delta = isRtl |
| 81 | + ? startX.current - e.clientX |
| 82 | + : e.clientX - startX.current; |
| 83 | + const newWidth = clampSidebarWidth(startWidth.current + delta); |
| 84 | + setSidebarWidth(newWidth); |
| 85 | + } |
| 86 | + |
| 87 | + // Inner drag |
| 88 | + if (isInnerDragging.current) { |
| 89 | + const delta = isRtl |
| 90 | + ? innerStartX.current - e.clientX |
| 91 | + : e.clientX - innerStartX.current; |
| 92 | + const newListWidth = innerStartListWidth.current + delta; |
| 93 | + setSidebarWidth((sw) => { |
| 94 | + const clampedList = Math.max(MIN_LIST_WIDTH, Math.min(sw - MIN_DRAWER_WIDTH, newListWidth)); |
| 95 | + const newRatio = clampedList / sw; |
| 96 | + setListRatio(newRatio); |
| 97 | + return sw; |
| 98 | + }); |
| 99 | + } |
| 100 | + }; |
| 101 | + |
| 102 | + const handleMouseUp = () => { |
| 103 | + if (isDragging.current) { |
| 104 | + isDragging.current = false; |
| 105 | + document.body.style.cursor = ""; |
| 106 | + document.body.style.userSelect = ""; |
| 107 | + setSidebarWidth((w) => { |
| 108 | + saveSidebarWidth(w); |
| 109 | + return w; |
| 110 | + }); |
| 111 | + } |
| 112 | + if (isInnerDragging.current) { |
| 113 | + isInnerDragging.current = false; |
| 114 | + document.body.style.cursor = ""; |
| 115 | + document.body.style.userSelect = ""; |
| 116 | + setListRatio((r) => { |
| 117 | + saveListRatio(r); |
| 118 | + return r; |
| 119 | + }); |
| 120 | + } |
| 121 | + }; |
| 122 | + |
| 123 | + document.addEventListener("mousemove", handleMouseMove); |
| 124 | + document.addEventListener("mouseup", handleMouseUp); |
| 125 | + return () => { |
| 126 | + document.removeEventListener("mousemove", handleMouseMove); |
| 127 | + document.removeEventListener("mouseup", handleMouseUp); |
| 128 | + }; |
| 129 | + }, []); |
| 130 | + |
| 131 | + const layoutStyle = { |
| 132 | + "--sidebar-list-width": `${listWidth}px`, |
| 133 | + "--sidebar-drawer-width": `${drawerWidth}px`, |
| 134 | + "--sidebar-total-width": `${sidebarWidth}px`, |
| 135 | + } as React.CSSProperties; |
| 136 | + |
| 137 | + return <IconContext.Provider value={{size: "14px"}}> |
| 138 | + <div className="maputnik-layout" style={layoutStyle}> |
| 139 | + {props.toolbar} |
| 140 | + <div className="maputnik-layout-main"> |
| 141 | + {props.codeEditor && <div className="maputnik-layout-code-editor"> |
| 142 | + <ScrollContainer> |
| 143 | + {props.codeEditor} |
| 144 | + </ScrollContainer> |
| 145 | + </div> |
| 146 | + } |
| 147 | + {!props.codeEditor && <> |
| 148 | + <div className="maputnik-layout-list"> |
| 149 | + {props.layerList} |
| 150 | + </div> |
| 151 | + <div |
| 152 | + className="maputnik-layout-resize-handle maputnik-layout-resize-handle--inner" |
| 153 | + data-testid="inner-resize-handle" |
| 154 | + onMouseDown={handleInnerMouseDown} |
| 155 | + title="Drag to resize list / editor split" |
| 156 | + tabIndex={-1} |
| 157 | + aria-hidden="true" |
| 158 | + /> |
| 159 | + <div className="maputnik-layout-drawer"> |
26 | 160 | <ScrollContainer> |
27 | | - {this.props.codeEditor} |
| 161 | + {props.layerEditor} |
28 | 162 | </ScrollContainer> |
29 | 163 | </div> |
30 | | - } |
31 | | - {!this.props.codeEditor && <> |
32 | | - <div className="maputnik-layout-list"> |
33 | | - {this.props.layerList} |
34 | | - </div> |
35 | | - <div className="maputnik-layout-drawer"> |
36 | | - <ScrollContainer> |
37 | | - {this.props.layerEditor} |
38 | | - </ScrollContainer> |
39 | | - </div> |
40 | | - </>} |
41 | | - {this.props.map} |
42 | | - </div> |
43 | | - {this.props.bottom && <div className="maputnik-layout-bottom"> |
44 | | - {this.props.bottom} |
45 | | - </div> |
46 | | - } |
47 | | - {this.props.modals} |
| 164 | + <div |
| 165 | + className="maputnik-layout-resize-handle" |
| 166 | + data-testid="sidebar-resize-handle" |
| 167 | + onMouseDown={handleMouseDown} |
| 168 | + title="Drag to resize sidebar" |
| 169 | + tabIndex={-1} |
| 170 | + aria-hidden="true" |
| 171 | + /> |
| 172 | + </>} |
| 173 | + {props.map} |
48 | 174 | </div> |
49 | | - </IconContext.Provider>; |
50 | | - } |
| 175 | + {props.bottom && <div className="maputnik-layout-bottom"> |
| 176 | + {props.bottom} |
| 177 | + </div> |
| 178 | + } |
| 179 | + {props.modals} |
| 180 | + </div> |
| 181 | + </IconContext.Provider>; |
51 | 182 | } |
52 | | - |
53 | | -const AppLayout = withTranslation()(AppLayoutInternal); |
54 | | -export default AppLayout; |
|
0 commit comments