Skip to content

Commit 646a187

Browse files
fix(library): harden overlay controls API
1 parent 5df4bec commit 646a187

23 files changed

Lines changed: 343 additions & 153 deletions

docs/library/api.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,10 @@ import "@tumaet/apollon/style.css"
4343
Container, lifecycle, and two layers of editor options.
4444

4545
**Container.** `className`, `style` (needs an explicit non-zero height), and
46-
`children` rendered alongside the canvas inside the editor's context
47-
provider.
46+
`children` rendered inside the editor's context provider. Omitting `children`
47+
renders the default palette, zoom, and minimap; passing children makes the chrome
48+
composition explicit, so include `<ApollonDefaultControls />` when custom children
49+
should keep the default controls.
4850

4951
**Theming.** `theme` (a `--apollon-*` token object, typically from
5052
`createApollonTheme(...)`) and `dataTheme` (`"light" | "dark"`) are spread onto

docs/library/api/overlay-controls.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ description: Floating canvas chrome in named regions — React, imperative, and
99
Apollon renders an editor's chrome — toolbars, palettes, rails, banners — as
1010
**floating controls anchored in named regions** of the canvas, not as separate
1111
bars stacked around it. Host chrome and the editor's own overlays share one
12-
collision-free layer, so they never overlap and the diagram knows how to make
13-
room for them.
12+
measured layout layer, so bands, rails, and corner controls deconflict through the
13+
same rules and the diagram knows how to make room for reserving chrome.
1414

1515
A control is positioned by **region** (where it sits) and can optionally
1616
**reserve space** so the diagram "makes way" for it. Reservation is measured, not
@@ -61,11 +61,17 @@ overlay store — only real option changes (region, inset, order, …) push an
6161
update.
6262

6363
```tsx no-check
64-
import { Apollon, ApollonControl, UMLDiagramType } from "@tumaet/apollon"
64+
import {
65+
Apollon,
66+
ApollonControl,
67+
ApollonDefaultControls,
68+
UMLDiagramType,
69+
} from "@tumaet/apollon"
6570

6671
function Editor() {
6772
return (
6873
<Apollon defaultType={UMLDiagramType.ClassDiagram}>
74+
<ApollonDefaultControls />
6975
<ApollonControl id="my-app:export" region="top-right" groupLabel="Export">
7076
<button type="button" onClick={exportDiagram}>
7177
Export
@@ -79,7 +85,10 @@ function Editor() {
7985
`ApollonControlProps` is [`OverlayControlOptions`](#overlaycontroloptions) plus
8086
`children: ReactNode`. The component renders `null` in the host tree; the visible
8187
output is the portaled `children`. The `id` must be stable — changing it
82-
re-registers a fresh control.
88+
re-registers a fresh control. Supplying children to `<Apollon>` means you own the
89+
composition; include `<ApollonDefaultControls />` (or the individual
90+
`<Apollon.Palette />`, `<Apollon.Zoom />`, `<Apollon.MiniMap />`) when a custom
91+
child should keep the default chrome visible.
8392

8493
## The imperative way: `addControl`
8594

@@ -127,8 +136,10 @@ reserved ids (`PALETTE_ID`, `ZOOM_ID`, `MINIMAP_ID`). You compose them the same
127136
two ways.
128137

129138
**React — as `<Apollon>` children.** Presence renders, omission hides, typed
130-
props reconfigure. Passing _any_ children opts out of the defaults, so you list
131-
exactly the chrome you want (an empty composition is a bare canvas).
139+
props reconfigure. Passing _any_ children makes the composition explicit, so you
140+
list exactly the chrome you want. Use `<ApollonDefaultControls />` to keep the
141+
standard palette + zoom + minimap next to custom children; pass `null` or an empty
142+
fragment for a bare canvas.
132143

133144
```tsx no-check
134145
import { Apollon, UMLDiagramType } from "@tumaet/apollon"
@@ -343,12 +354,12 @@ control never drags the diagram.
343354
`region="bottom-right"` with `inset={{ bottom: "auto" }}` instead: the diagram
344355
still reserves bottom room for the island, but unrelated bottom-left chrome
345356
stays flush.
346-
- **Corners clear full-width bands, not empty rail columns.** Top/bottom corner
357+
- **Corners clear full-width bands and same-side rails.** Top/bottom corner
347358
controls sit below a header and above a footer structurally. Side rails share
348-
the side track with the corner slots, so short rails leave bottom/top corners
349-
flush instead of reserving an empty column. The built-in minimap uses the same
350-
corner slots, so it stacks with host controls in its region instead of applying
351-
its own side offsets.
359+
the side track with the corner slots and are padded by the measured same-side
360+
corner extent, so short rails leave corners flush while tall rails avoid covering
361+
them. The built-in minimap uses the same corner slots, so it stacks with host
362+
controls in its region instead of applying its own side offsets.
352363
- **Selection-anchored toolbars**`<Apollon.SelectionToolbar>` (Figma/tldraw
353364
style): a screen-space, constant-size toolbar that follows the current selection.
354365
Distinct from `on-canvas`, which lives in diagram space and scales with zoom.

library/lib/apollon-editor.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
import { createOverlayStore, type OverlayStore } from "./overlay/overlayStore"
4242
import {
4343
assertBuiltInControlRegion,
44+
preserveBuiltInControlKind,
4445
defaultControls,
4546
} from "./chrome/builtins/controls"
4647
import { mergeLabels } from "./i18n/labels"
@@ -415,11 +416,16 @@ export class ApollonEditor {
415416
throw new Error(
416417
`[ApollonEditor] updateControl: unknown region: ${patch.region}`
417418
)
418-
if (patch.region !== undefined) {
419-
assertBuiltInControlRegion(id, patch.region)
420-
}
421419
// Pin id last so a stray `patch.id` can't fork the control under a new key.
422-
this.overlayStore.getState().register({ ...existing, ...patch, id })
420+
const next = { ...existing, ...patch, id }
421+
// Built-in descriptors carry a private renderer-kind marker so their own
422+
// runtime updates stay within the regions their renderers support. Replacing
423+
// the renderer intentionally drops that marker: a host control at PALETTE_ID /
424+
// MINIMAP_ID is a normal control and can move to any valid region.
425+
if (patch.render === undefined) preserveBuiltInControlKind(existing, next)
426+
if (patch.region !== undefined)
427+
assertBuiltInControlRegion(next, patch.region)
428+
this.overlayStore.getState().register(next)
423429
}
424430

425431
/**

library/lib/chrome/builtins/controls.tsx

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,45 @@ function assertBuiltInRegion(
6868
}
6969
}
7070

71-
export function assertBuiltInControlRegion(id: string, region: string): void {
72-
if (id === PALETTE_ID) {
71+
const BUILT_IN_CONTROL_KIND = Symbol("apollon-built-in-control")
72+
type BuiltInControlKind = "palette" | "minimap"
73+
type BuiltInControlInput = OverlayControlInput & {
74+
[BUILT_IN_CONTROL_KIND]?: BuiltInControlKind
75+
}
76+
77+
function markBuiltIn<T extends OverlayControlInput>(
78+
control: T,
79+
kind: BuiltInControlKind
80+
): T {
81+
Object.defineProperty(control, BUILT_IN_CONTROL_KIND, {
82+
value: kind,
83+
enumerable: false,
84+
})
85+
return control
86+
}
87+
88+
function builtInKind(
89+
control: OverlayControlInput
90+
): BuiltInControlKind | undefined {
91+
return (control as BuiltInControlInput)[BUILT_IN_CONTROL_KIND]
92+
}
93+
94+
export function preserveBuiltInControlKind(
95+
from: OverlayControlInput,
96+
to: OverlayControlInput
97+
): OverlayControlInput {
98+
const kind = builtInKind(from)
99+
return kind ? markBuiltIn(to, kind) : to
100+
}
101+
102+
export function assertBuiltInControlRegion(
103+
control: OverlayControlInput,
104+
region: string
105+
): void {
106+
const kind = builtInKind(control)
107+
if (kind === "palette") {
73108
assertBuiltInRegion("paletteControl", region, PALETTE_REGIONS)
74-
} else if (id === MINIMAP_ID) {
109+
} else if (kind === "minimap") {
75110
assertBuiltInRegion("miniMapControl", region, MINIMAP_REGIONS)
76111
}
77112
}
@@ -81,12 +116,15 @@ export function paletteControl(
81116
): OverlayControlInput {
82117
const region = options.region ?? "left-rail"
83118
assertBuiltInRegion("paletteControl", region, PALETTE_REGIONS)
84-
return {
85-
...options,
86-
id: PALETTE_ID,
87-
region,
88-
render: () => <Sidebar />,
89-
}
119+
return markBuiltIn(
120+
{
121+
...options,
122+
id: PALETTE_ID,
123+
region,
124+
render: () => <Sidebar />,
125+
},
126+
"palette"
127+
)
90128
}
91129

92130
export function zoomControl({
@@ -134,12 +172,15 @@ export function miniMapControl({
134172
...placement
135173
}: MiniMapControlOptions = {}): OverlayControlInput {
136174
assertBuiltInRegion("miniMapControl", region, MINIMAP_REGIONS)
137-
return {
138-
...placement,
139-
id: MINIMAP_ID,
140-
region,
141-
render: () => <BuiltInMiniMap pannable={pannable} zoomable={zoomable} />,
142-
}
175+
return markBuiltIn(
176+
{
177+
...placement,
178+
id: MINIMAP_ID,
179+
region,
180+
render: () => <BuiltInMiniMap pannable={pannable} zoomable={zoomable} />,
181+
},
182+
"minimap"
183+
)
143184
}
144185

145186
/** The editor's default chrome — palette, zoom/history cluster, minimap. */

library/lib/chrome/useRovingToolbar.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useRef, type KeyboardEvent } from "react"
1+
import { useCallback, useLayoutEffect, useRef, type KeyboardEvent } from "react"
22

33
/** Enabled, focusable buttons in DOM (left→right) order. */
44
function focusableButtons(el: HTMLElement): HTMLButtonElement[] {
@@ -30,7 +30,7 @@ function syncTabStops(el: HTMLElement) {
3030
export function useRovingToolbar<T extends HTMLElement = HTMLDivElement>() {
3131
const ref = useRef<T>(null)
3232

33-
useEffect(() => {
33+
useLayoutEffect(() => {
3434
const el = ref.current
3535
if (!el) return
3636
syncTabStops(el)

library/lib/components/CustomMiniMap.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react"
1+
import { useMemo, useState, type CSSProperties } from "react"
22
import {
33
MiniMap,
44
MiniMapNodeProps,
@@ -129,6 +129,17 @@ export const CustomMiniMap = ({
129129
canvasWidth > 0 && canvasWidth < MINIMAP_EXPAND_MIN_WIDTH
130130

131131
const panelPositionClasses = position.replace("-", " ")
132+
const managedCollapseStyle = useMemo<CSSProperties>(() => {
133+
const [vertical, horizontal] = position.split("-") as [
134+
"top" | "bottom",
135+
"left" | "center" | "right",
136+
]
137+
return {
138+
position: "absolute",
139+
[vertical]: 0,
140+
...(horizontal === "left" ? { left: 0 } : { right: 0 }),
141+
}
142+
}, [position])
132143

133144
if (minimapCollapsed || tooNarrowToExpand) {
134145
const content = (
@@ -189,6 +200,7 @@ export const CustomMiniMap = ({
189200
{map}
190201
<div
191202
className={`react-flow__panel ${panelPositionClasses} apollon-mm-panel apollon-mm-collapse`}
203+
style={managedCollapseStyle}
192204
>
193205
{collapse}
194206
</div>

library/lib/components/Sidebar.tsx

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ export const Sidebar = () => {
6565
[diagramType]
6666
)
6767

68-
// The palette is a rail overlay control. The rail itself stays height-stable
69-
// when unrelated corner chrome moves; the palette only caps its own grid when
70-
// same-side bottom chrome would otherwise overlap it in a short viewport.
68+
// The palette is a rail overlay control. Generic rail/corner clearance lives in
69+
// OverlayLayer; the palette only reads the rail's current content box to size
70+
// its internal grid.
7171
const isRightRail = useOverlayStore(
7272
(state) => state.controls["apollon:palette"]?.region === "right-rail"
7373
)
@@ -81,24 +81,15 @@ export const Sidebar = () => {
8181
if (!aside || !band || !canvasEl) return
8282
const mobileQuery = window.matchMedia(MOBILE_VIEW_QUERY)
8383
const measure = () => {
84-
// Start with the rail band's stable height, then cap it at the same-side
85-
// bottom corner only when that corner is occupied. That preserves palette
86-
// size-invariance for unrelated chrome (e.g. bottom-right controls do not
87-
// resize a left palette) while still preventing a real same-side collision
88-
// with the zoom/minimap cluster on short mobile viewports.
8984
const gap =
9085
parseFloat(
9186
getComputedStyle(aside).getPropertyValue("--apollon-chrome-gap")
9287
) || 8
93-
const bandRect = band.getBoundingClientRect()
94-
const bottomRegion = isRightRail ? "bottom-right" : "bottom-left"
95-
const bottomControl = canvasEl.querySelector<HTMLElement>(
96-
`[data-apollon-region="${bottomRegion}"] [data-apollon-control]`
97-
)
98-
const bottomLimit = bottomControl
99-
? bottomControl.getBoundingClientRect().top - gap
100-
: bandRect.bottom - gap
101-
const h = Math.max(0, bottomLimit - bandRect.top - gap)
88+
const bandStyle = getComputedStyle(band)
89+
const verticalPadding =
90+
(parseFloat(bandStyle.paddingTop) || 0) +
91+
(parseFloat(bandStyle.paddingBottom) || 0)
92+
const h = Math.max(0, band.clientHeight - verticalPadding - 2 * gap)
10293
const w = canvasEl.getBoundingClientRect().width
10394
const compact = mobileQuery.matches
10495
setCanvas((prev) =>
@@ -111,16 +102,12 @@ export const Sidebar = () => {
111102
const observer = new ResizeObserver(measure)
112103
observer.observe(band)
113104
observer.observe(canvasEl)
114-
for (const region of ["bottom-left", "bottom-right"]) {
115-
const corner = canvasEl.querySelector(`[data-apollon-region="${region}"]`)
116-
if (corner) observer.observe(corner)
117-
}
118105
mobileQuery.addEventListener("change", measure)
119106
return () => {
120107
observer.disconnect()
121108
mobileQuery.removeEventListener("change", measure)
122109
}
123-
}, [isRightRail])
110+
}, [])
124111

125112
// The color-description element is the last grid cell.
126113
const cellCount = paletteItems.length + 1

library/lib/components/popovers/DefaultNodeEditPopover.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,8 @@ import { useDiagramStore } from "@/store/context"
33
import { useShallow } from "zustand/shallow"
44
import { PopoverProps } from "./types"
55
import { NodeStyleEditor } from "@/components/styleEditor"
6-
import {
7-
nodeTypeLabel,
8-
rendersNameLabel,
9-
supportsMultilineName,
10-
} from "@/utils/nodeUtils"
6+
import { rendersNameLabel, supportsMultilineName } from "@/utils/nodeUtils"
7+
import { useLabels } from "@/i18n/useLabels"
118
import { PopoverLayout } from "./PopoverLayout"
129

1310
export const DefaultNodeEditPopover: React.FC<PopoverProps> = ({
@@ -16,6 +13,7 @@ export const DefaultNodeEditPopover: React.FC<PopoverProps> = ({
1613
sideElements = [],
1714
placeholder,
1815
}) => {
16+
const t = useLabels()
1917
const { nodes, setNodes } = useDiagramStore(
2018
useShallow((state) => ({
2119
nodes: state.nodes,
@@ -48,7 +46,7 @@ export const DefaultNodeEditPopover: React.FC<PopoverProps> = ({
4846
const nodeData = node.data as DefaultNodeProps
4947

5048
return (
51-
<PopoverLayout title={nodeTypeLabel(node.type)}>
49+
<PopoverLayout title={t.nodeTypeLabel(node.type)}>
5250
<NodeStyleEditor
5351
nodeData={nodeData}
5452
handleDataFieldUpdate={handleDataFieldUpdate}

library/lib/components/popovers/DefaultNodeGiveFeedbackPopover.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import { GiveFeedbackAssessmentBox } from "./GiveFeedbackAssessmentBox"
66
import { Button } from "@tumaet/ui/components/button"
77
import { useGoToNextAssessment } from "@/hooks"
88
import { useLabels } from "@/i18n/useLabels"
9-
import { nodeTypeLabel } from "@/utils/nodeUtils"
109
import { PopoverLayout } from "./PopoverLayout"
1110

1211
export const DefaultNodeGiveFeedbackPopover = ({ elementId }: PopoverProps) => {
@@ -27,7 +26,7 @@ export const DefaultNodeGiveFeedbackPopover = ({ elementId }: PopoverProps) => {
2726
elementId={elementId}
2827
name={nodeData.name}
2928
elementType="node"
30-
typeLabel={nodeTypeLabel(node.type)}
29+
typeLabel={t.nodeTypeLabel(node.type)}
3130
/>
3231
<Button variant="outline" onClick={handleGoToNextAssessment}>
3332
{t.nextAssessment}

library/lib/components/popovers/DefaultNodeSeeFeedbackPopover.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import { PopoverProps } from "./types"
55
import { SeeFeedbackAssessmentBox } from "./SeeFeedbackAssessmentBox"
66
import { useGoToNextAssessment } from "@/hooks"
77
import { useLabels } from "@/i18n/useLabels"
8-
import { nodeTypeLabel } from "@/utils/nodeUtils"
98
import { Button } from "@tumaet/ui/components/button"
109
import { PopoverLayout } from "./PopoverLayout"
1110

@@ -25,7 +24,7 @@ export const DefaultNodeSeeFeedbackPopover = ({ elementId }: PopoverProps) => {
2524
elementId={elementId}
2625
name={nodeData.name}
2726
type={node.type || t.node}
28-
typeLabel={nodeTypeLabel(node.type)}
27+
typeLabel={t.nodeTypeLabel(node.type)}
2928
/>
3029
<Button variant="outline" onClick={handleGoToNextAssessment}>
3130
{t.nextAssessment}

0 commit comments

Comments
 (0)