-
-
Notifications
You must be signed in to change notification settings - Fork 628
Expand file tree
/
Copy pathPluginRightPanel.tsx
More file actions
321 lines (310 loc) · 13.4 KB
/
Copy pathPluginRightPanel.tsx
File metadata and controls
321 lines (310 loc) · 13.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
import {
closeRightPanel,
collapseRightPanel,
getRightPanel,
listRightPanels,
moveActiveRightPanelDock,
openRightPanel,
type RightPanelDock,
setActiveRightPanelDock,
} from "@geolibre/plugins";
import { Button } from "@geolibre/ui";
import {
ArrowLeftToLine,
ArrowRightToLine,
Columns2,
Combine,
PanelLeft,
PanelLeftClose,
PanelRight,
PanelRightClose,
X,
} from "lucide-react";
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useEffect,
useRef,
} from "react";
import { useTranslation } from "react-i18next";
import { useRightPanelState } from "../../hooks/useRightPanels";
import { clamp } from "../../lib/clamp";
import { isImageSource } from "../../lib/icon-source";
export const PLUGIN_PANEL_DEFAULT_WIDTH = 320;
const MIN_WIDTH = 240;
const MAX_WIDTH = 640;
/** Clamp a plugin-panel width to the allowed range. Shared with the shell. */
export function clampPluginPanelWidth(width: number): number {
return clamp(width, MIN_WIDTH, MAX_WIDTH);
}
interface PluginRightPanelProps {
/** Which dock position this instance renders. */
dock: RightPanelDock;
/**
* The active panel's content host element, created and rendered into once by
* the shell. The matched slot adopts it via `appendChild`, so moving the panel
* between docks relocates the same DOM (preserving the plugin's state) instead
* of tearing it down and re-rendering.
*/
contentEl: HTMLElement;
/**
* The active panel's width in px. Owned by the shell and shared between the
* dock-slot instances so a user's resize survives moving the panel between
* positions. Lifting it to the shell (rather than a module-level global) keeps
* it per-app-instance, which matters for the multi-instance Jupyter embed.
*/
width: number;
/** Update the shared panel width (clamped by this component). */
onWidthChange: (width: number) => void;
}
/**
* Renders the active plugin-owned dockable panel when it is docked at this
* instance's `dock` position.
*
* One instance is mounted per dock position (`left-of-layers`, `right-of-layers`,
* `left-of-style`, `right-of-style`); each renders only when the active panel is
* docked there, so a user can step the panel between positions with the header's
* move buttons (issue #712). The built-in panel on the docked side (Layers or
* Style) collapses while the plugin panel is expanded next to it (the shell
* handles that). The panel content is owned by the plugin via `render(container)`
* (plain DOM); the host provides the dock chrome (header, collapse rail, resize
* handle, move/collapse/close buttons). Renders nothing when no plugin panel is
* docked here.
*
* @param props.dock - The dock position this instance renders.
* @returns The plugin panel aside, or null when no panel is docked here.
*/
export function PluginRightPanel({ dock, contentEl, width, onWidthChange }: PluginRightPanelProps) {
const { t } = useTranslation();
const { activeId, collapsed, dock: activeDock, panelDocks } = useRightPanelState();
const contentRef = useRef<HTMLDivElement | null>(null);
const panel = activeId ? getRightPanel(activeId) : undefined;
const matched = activeId !== null && panel != null && activeDock === dock;
// Layers-side docks sit to the left: their border and resize handle face right
// (toward the map). Style-side docks face left (toward the map). The
// `replace-layers` shared-rail mode is a layers-side dock too.
const isLayersSide =
dock === "left-of-layers" || dock === "right-of-layers" || dock === "replace-layers";
// The shared-rail modes: the panel shares the Style (replace-style) or Layers
// (replace-layers) rail (rendered by the host's SharedSidebar), so it has no
// move buttons and no rail of its own; its collapsed entry lives in that single
// shared rail instead.
const isSharedRail = dock === "replace-style" || dock === "replace-layers";
const dockPanels = isSharedRail
? []
: listRightPanels().filter((candidate) =>
candidate.id === activeId ? activeDock === dock : panelDocks[candidate.id] === dock,
);
// Adopt the shared content host (rendered once by the shell) into this slot
// while it owns the panel. appendChild moves the element, so stepping the
// panel between docks relocates the same DOM and preserves the plugin's state
// rather than re-rendering it. `collapsed` is a dependency because the
// shared-rail mode unmounts the content wrapper while collapsed (it renders
// nothing), so the host must be re-adopted into the fresh wrapper when the
// panel expands again; for the positional docks the wrapper merely hides, so
// re-adopting the already-attached host is a harmless no-op.
useEffect(() => {
if (!matched) return;
const wrapper = contentRef.current;
if (!wrapper) return;
wrapper.appendChild(contentEl);
}, [matched, contentEl, collapsed]);
const panelRail =
dockPanels.length > 0 ? (
<aside
aria-label={t("pluginPanel.collapsedLabel", { title: t("toolbar.menu.plugins") })}
className={`flex h-11 w-full shrink-0 items-center gap-1 overflow-x-auto border-t bg-card px-2 md:h-auto md:w-11 md:flex-col md:overflow-x-visible md:overflow-y-auto md:border-t-0 md:px-0 md:py-2 ${isLayersSide ? "md:border-e" : "md:border-s"}`}
>
{dockPanels.map((candidate) => {
const candidateIcon =
candidate.icon && isImageSource(candidate.icon) ? (
<img src={candidate.icon} alt="" className="h-4 w-4 object-contain" />
) : isLayersSide ? (
<PanelLeft className="h-4 w-4" />
) : (
<PanelRight className="h-4 w-4" />
);
const expanded = candidate.id === activeId && !collapsed;
return (
<button
key={candidate.id}
type="button"
aria-pressed={expanded}
title={candidate.title}
aria-label={candidate.title}
onClick={() =>
expanded ? collapseRightPanel(candidate.id) : openRightPanel(candidate.id)
}
className={`flex items-center gap-2 rounded px-1.5 py-1.5 md:flex-col md:px-1 md:py-2 ${
expanded
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
}`}
>
{candidateIcon}
<span className="text-[10px] font-semibold uppercase tracking-wide md:[writing-mode:vertical-rl] md:rotate-180">
{candidate.title}
</span>
</button>
);
})}
</aside>
) : null;
if (!matched || !panel) return panelRail;
// When collapsed in shared-rail mode the host's single shared rail shows this
// panel's entry, so render nothing here (no second rail beside Style).
if (isSharedRail && collapsed) return null;
// Collapsed at a positional dock: `panelRail` already carries this panel's
// entry (alongside any other panel docked here), so nothing else renders and
// everything below this point runs expanded.
if (collapsed) return panelRail;
const handleResizeStart = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
// Attach the move/end listeners to the handle element (not window) so they
// are discarded with it if the panel unmounts mid-drag, and capture the
// pointer so a drag past the viewport edge keeps tracking. pointercancel is
// handled alongside pointerup so an interrupted drag still cleans up.
const el = event.currentTarget;
el.setPointerCapture(event.pointerId);
const startX = event.clientX;
const startWidth = width;
const dirSign = getComputedStyle(el).direction === "rtl" ? -1 : 1;
const handleMove = (move: PointerEvent) => {
// The resizable edge faces away from the dock side: dragging it widens the
// panel.
const delta = dirSign * (isLayersSide ? move.clientX - startX : startX - move.clientX);
onWidthChange(clamp(startWidth + delta, MIN_WIDTH, MAX_WIDTH));
};
const handleEnd = () => {
if (el.hasPointerCapture(event.pointerId)) {
el.releasePointerCapture(event.pointerId);
}
el.removeEventListener("pointermove", handleMove);
el.removeEventListener("pointerup", handleEnd);
el.removeEventListener("pointercancel", handleEnd);
};
el.addEventListener("pointermove", handleMove);
el.addEventListener("pointerup", handleEnd);
el.addEventListener("pointercancel", handleEnd);
};
const borderSide = isLayersSide ? "md:border-e" : "md:border-s";
// Dock names describe the LTR arrangement, so the visual move-left/right
// actions and their guards swap in a right-to-left layout.
const isRtl = document.documentElement.dir === "rtl";
const canMoveLeft = activeDock !== (isRtl ? "right-of-style" : "left-of-layers");
const canMoveRight = activeDock !== (isRtl ? "left-of-layers" : "right-of-style");
return (
<>
<aside
aria-label={panel.title}
style={{ "--plugin-right-panel-width": `${width}px` } as CSSProperties}
className={`relative flex max-h-[min(24rem,42vh)] supports-[max-height:1dvh]:max-h-[min(24rem,42dvh)] w-full shrink-0 flex-col border-t bg-card max-md:absolute max-md:inset-x-0 max-md:bottom-0 max-md:z-30 max-md:shadow-xl md:max-h-none md:w-[var(--plugin-right-panel-width)] md:border-t-0 ${borderSide}`}
>
<div
role="separator"
aria-orientation="vertical"
aria-label={t("pluginPanel.resize")}
className={`absolute ${isLayersSide ? "-end-1 border-e" : "-start-1 border-s"} top-0 z-20 hidden h-full w-2 cursor-col-resize touch-none select-none border-transparent hover:border-primary md:block`}
onPointerDown={handleResizeStart}
/>
<div className="flex items-center justify-between border-b px-3 py-1.5">
<span className="truncate text-sm font-semibold">{panel.title}</span>
<div className="flex items-center gap-1">
{!isSharedRail ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("pluginPanel.moveLeft")}
aria-label={t("pluginPanel.moveLeft")}
disabled={!canMoveLeft}
onClick={() => moveActiveRightPanelDock(isRtl ? "right" : "left")}
>
<ArrowLeftToLine className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("pluginPanel.moveRight")}
aria-label={t("pluginPanel.moveRight")}
disabled={!canMoveRight}
onClick={() => moveActiveRightPanelDock(isRtl ? "left" : "right")}
>
<ArrowRightToLine className="h-4 w-4" />
</Button>
</>
) : null}
{isSharedRail ? (
// Pop the panel out of the shared rail back to a movable positional
// panel on the same side (where the move buttons return).
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("pluginPanel.detach")}
aria-label={t("pluginPanel.detach")}
onClick={() =>
setActiveRightPanelDock(isLayersSide ? "right-of-layers" : "right-of-style")
}
>
<Columns2 className="h-4 w-4" />
</Button>
) : (
// Merge the movable panel into the shared rail on its current side:
// a layers-side panel joins the Layers rail, a style-side panel the
// Style rail.
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={
isLayersSide
? t("pluginPanel.mergeIntoLayersRail")
: t("pluginPanel.mergeIntoStyleRail")
}
aria-label={
isLayersSide
? t("pluginPanel.mergeIntoLayersRail")
: t("pluginPanel.mergeIntoStyleRail")
}
onClick={() =>
setActiveRightPanelDock(isLayersSide ? "replace-layers" : "replace-style")
}
>
<Combine className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("pluginPanel.collapse")}
aria-label={t("pluginPanel.collapse")}
onClick={() => collapseRightPanel(activeId)}
>
{isLayersSide ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelRightClose className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
title={t("pluginPanel.close")}
aria-label={t("pluginPanel.close")}
onClick={() => closeRightPanel(activeId)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
<div ref={contentRef} className="min-h-0 flex-1 overflow-auto" />
</aside>
{panelRail}
</>
);
}