Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion demo/js/planning.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const interactPlugin = createInteractPlugin({
backgroundColor: { outdoor: '#0b0c0c', dark: '#ffffff' },
foregroundColor: { outdoor: '#ffff', dark: '#0b0c0c' }
},
// interactionModes: ['selectMarker'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
interactionModes: ['placeMarker'], // e.g. ['selectMarker'], ['selectFeature'], ['placeMarker'], or combinations
// multiSelect: true
})

Expand Down
1 change: 1 addition & 0 deletions plugins/beta/scale-bar/src/scaleBar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
.im-c-scale-bar {
position: relative;
margin-left: auto;
margin-bottom: 1px; // Account for halo
text-align: right;
padding-bottom: 5px;
color: var(--map-overlay-foreground-color);
Expand Down
20 changes: 5 additions & 15 deletions src/App/components/Viewport/Viewport.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useRef, useEffect, useState } from 'react'
import { EVENTS as events } from '../../../config/events.js'
import { createPortal } from 'react-dom'
import { useConfig } from '../../store/configContext.js'

import { useApp } from '../../store/appContext.js'
import { useMap } from '../../store/mapContext.js'
import { MapController } from './MapController.jsx'
Expand All @@ -14,9 +15,10 @@ import { Markers } from '../Markers/Markers'

// eslint-disable-next-line camelcase, react/jsx-pascal-case
// sonarjs/disable-next-line function-name
export const Viewport = ({ keyboardHintPortalRef }) => {
export const Viewport = () => {
const { id, mapProvider, mapLabel, keyboardHintText } = useConfig()
const { interfaceType, mode, previousMode, layoutRefs, safeZoneInset } = useApp()
const { mainRef } = layoutRefs
const { mapSize } = useMap()

const mapContainerRef = useRef(null)
Expand Down Expand Up @@ -49,18 +51,6 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
}
}, [mode])

// Toggle external class based on keyboard hint
useEffect(() => {
const mainEl = layoutRefs.mainRef?.current
if (!mainEl) {
return undefined
}

mainEl.classList.toggle('im-o-app__main--keyboard-hint-visible', showHint)

return () => mainEl?.classList.remove('im-o-app__main--keyboard-hint-visible')
}, [showHint])

return (
<>
<MapController mapContainerRef={mapContainerRef} />
Expand All @@ -74,14 +64,14 @@ export const Viewport = ({ keyboardHintPortalRef }) => {
onBlur={handleBlur}
ref={layoutRefs.viewportRef}
>
{showHint && keyboardHintPortalRef?.current && createPortal(
{showHint && mainRef?.current && createPortal(
<div
className='im-c-viewport__keyboard-hint'
aria-hidden='true'
ref={keyboardHintRef}
dangerouslySetInnerHTML={{ __html: keyboardHintText }}
/>,
keyboardHintPortalRef.current
mainRef.current
)}
<div className='im-c-viewport__map-container' ref={mapContainerRef} />
<div className='im-c-viewport__features' />
Expand Down
2 changes: 2 additions & 0 deletions src/App/components/Viewport/Viewport.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@

.im-c-viewport__keyboard-hint {
position: absolute;
bottom: var(--keyboard-hint-bottom, var(--primary-gap));
left: 50%;
transform: translateX(-50%);
z-index: 1001;
text-wrap: nowrap;

color: var(--tooltip-foreground-color);
Expand Down
49 changes: 16 additions & 33 deletions src/App/components/Viewport/Viewport.test.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react'
import { render, fireEvent, cleanup } from '@testing-library/react'
import { render, cleanup } from '@testing-library/react'
import { Viewport } from './Viewport.jsx'
import { useConfig } from '../../store/configContext.js'
import { useApp } from '../../store/appContext.js'
Expand All @@ -23,15 +23,18 @@ jest.mock('../CrossHair/CrossHair', () => ({ CrossHair: jest.fn(() => <div data-
jest.mock('../Markers/Markers', () => ({ Markers: jest.fn(() => <div data-testid='markers' />) }))

describe('Viewport', () => {
let keyboardHintPortalRef
let viewportEl
let mainEl
const mockMapProvider = { initMap: jest.fn(), updateMap: jest.fn(), clearHighlightedLabel: jest.fn() }

beforeEach(() => {
cleanup()
jest.clearAllMocks()

keyboardHintPortalRef = { current: document.createElement('div') }
document.body.appendChild(keyboardHintPortalRef.current)
viewportEl = document.createElement('div')
mainEl = document.createElement('div')
document.body.appendChild(viewportEl)
document.body.appendChild(mainEl)

// ---------------------------
// Hook mocks
Expand All @@ -47,7 +50,7 @@ describe('Viewport', () => {
interfaceType: 'desktop',
mode: 'default',
previousMode: 'default',
layoutRefs: { viewportRef: { current: null }, mainRef: { current: null }, safeZoneRef: { current: null } },
layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewportEl }, safeZoneRef: { current: null } },
safeZoneInset: {}
})

Expand Down Expand Up @@ -75,14 +78,17 @@ describe('Viewport', () => {
useMapEvents.mockImplementation(() => {})
})

afterEach(() => document.body.removeChild(keyboardHintPortalRef.current))
afterEach(() => {
viewportEl.remove()
mainEl.remove()
})

const renderViewport = () => {
const { container, rerender, unmount } = render(<Viewport keyboardHintPortalRef={keyboardHintPortalRef} />)
const { container, rerender, unmount } = render(<Viewport />)
const viewport = container.querySelector('.im-c-viewport')
const mapContainer = container.querySelector('.im-c-viewport__map-container')
const safeZone = container.querySelector('.im-c-viewport__safezone')
const keyboardHint = keyboardHintPortalRef.current.querySelector('.im-c-viewport__keyboard-hint')
const keyboardHint = mainEl.querySelector('.im-c-viewport__keyboard-hint')
const crossHair = container.querySelector('[data-testid="cross-hair"]')
const markers = container.querySelector('[data-testid="markers"]')
return { viewport, mapContainer, safeZone, keyboardHint, crossHair, markers, rerender, unmount }
Expand All @@ -109,14 +115,6 @@ describe('Viewport', () => {
expect(keyboardHint.innerHTML).toBe('Press arrow keys')
})

it('handles focus and blur events updating keyboard hint visibility', () => {
const { viewport, keyboardHint } = renderViewport()
fireEvent.focus(viewport)
fireEvent.blur(viewport)
expect(keyboardHint).toBeInTheDocument()
expect(keyboardHint.innerHTML).toBe('Press arrow keys')
})

it('attaches keyboard shortcuts', () => {
renderViewport()
expect(useKeyboardShortcuts).toHaveBeenCalled()
Expand All @@ -137,25 +135,10 @@ describe('Viewport', () => {
interfaceType: 'desktop',
mode: 'edit',
previousMode: 'default',
layoutRefs: { viewportRef: { current: viewport }, mainRef: { current: null }, safeZoneRef: { current: null } },
layoutRefs: { mainRef: { current: mainEl }, viewportRef: { current: viewport }, safeZoneRef: { current: null } },
safeZoneInset: {}
})
rerender(<Viewport keyboardHintPortalRef={keyboardHintPortalRef} />)
rerender(<Viewport />)
expect(focusMock).toHaveBeenCalled()
})

it('toggles main element class for keyboard hint and cleans up on unmount', () => {
const mainEl = document.createElement('div')
useApp.mockReturnValueOnce({
interfaceType: 'desktop',
mode: 'default',
previousMode: 'default',
layoutRefs: { viewportRef: { current: null }, mainRef: { current: mainEl }, safeZoneRef: { current: null } },
safeZoneInset: {}
})
const { unmount } = renderViewport()
expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(true)
unmount()
expect(mainEl.classList.contains('im-o-app__main--keyboard-hint-visible')).toBe(false)
})
})
136 changes: 64 additions & 72 deletions src/App/hooks/useLayoutMeasurements.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,80 +51,72 @@ const subSlotMaxHeight = (columnHeight, siblingButtons, gap) =>
* It does not dispatch the safe zone — safe zone dispatch is owned entirely by
* Effect 3 to prevent jumps on panel open/close and other non-structural resizes.
*/
export function useLayoutMeasurements () {
const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
const { mapSize, isMapReady } = useMap()

function calculateLayout (layoutRefs) {
const {
appContainerRef,
mainRef,
bannerRef,
topRef,
topLeftColRef,
topRightColRef,
leftTopRef,
leftBottomRef,
rightTopRef,
rightBottomRef,
bottomRef,
bottomRightRef,
attributionsRef,
drawerRef,
actionsRef
appContainerRef, mainRef, topRef, topLeftColRef, topRightColRef,
bottomRef, attributionsRef, bottomRightRef, leftTopRef, leftBottomRef,
rightTopRef, rightBottomRef
} = layoutRefs

// --------------------------------
// 1. Calculate layout CSS vars (pure side effect, no dispatch)
// --------------------------------
const calculateLayout = () => {
const appContainer = appContainerRef.current
const main = mainRef.current
const top = topRef.current
const topLeftCol = topLeftColRef.current
const topRightCol = topRightColRef.current
const bottom = bottomRef.current
const attributions = attributionsRef.current

if ([main, top, bottom].some(r => !r)) {
return
}
const appContainer = appContainerRef.current
const main = mainRef.current
const top = topRef.current
const topLeftCol = topLeftColRef.current
const topRightCol = topRightColRef.current
const bottom = bottomRef.current
const attributions = attributionsRef.current

const root = document.documentElement
const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)

// === Top column width ===
appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)

// === Left container offsets ===
const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)

// === Right container offsets ===
// Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
// bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
// the padding between the bottom of the bottom container and the bottom of main.
const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)

// === Sub-slot panel max-heights ===
appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
if ([main, top, bottom].some(r => !r)) {
return
}

const root = document.documentElement
const dividerGap = Number.parseInt(getComputedStyle(root).getPropertyValue('--divider-gap'), 10)

// === Top column width ===
appContainer.style.setProperty('--top-col-width', `${topColWidth(topLeftCol.offsetWidth, topRightCol.offsetWidth)}px`)

// === Left container offsets ===
const leftOffsetTop = topLeftCol.offsetHeight + top.offsetTop
const leftColumnHeight = bottom.offsetTop - leftOffsetTop - dividerGap
appContainer.style.setProperty('--left-offset-top', `${leftOffsetTop}px`)
appContainer.style.setProperty('--left-offset-bottom', `${main.offsetHeight - bottom.offsetTop + dividerGap}px`)
appContainer.style.setProperty('--left-top-max-height', `${leftColumnHeight}px`)

// === Right container offsets ===
// Mirrors the top formula (topRightCol.offsetHeight + top.offsetTop):
// bottomRight.offsetHeight is 0 when no buttons so the offset collapses to just
// the padding between the bottom of the bottom container and the bottom of main.
const bottomRightHeight = bottomRightRef?.current?.offsetHeight ?? 0
const bottomContainerPad = main.offsetHeight - bottom.offsetTop - bottom.offsetHeight
const rightOffsetTop = topRightCol.offsetHeight + top.offsetTop
const rightEffectiveBottom = bottom.offsetTop + bottom.offsetHeight - bottomRightHeight
const rightColumnHeight = rightEffectiveBottom - rightOffsetTop - dividerGap
const rightOffsetBottom = bottomContainerPad + (bottomRightHeight > 0 ? (bottomRightHeight + dividerGap) : attributions.offsetHeight)
appContainer.style.setProperty('--right-offset-top', `${rightOffsetTop}px`)
appContainer.style.setProperty('--right-offset-bottom', `${rightOffsetBottom}px`)
appContainer.style.setProperty('--right-top-max-height', `${rightColumnHeight}px`)

// === Keyboard hint bottom offset ===
// Distance from the bottom of im-o-app__bottom to the bottom of im-o-app__main.
// Used to position the hint above the bottom bar (and above drawers on mobile).
appContainer.style.setProperty('--keyboard-hint-bottom', `${main.offsetHeight - bottom.offsetTop - bottom.offsetHeight}px`)

// === Sub-slot panel max-heights ===
appContainer.style.setProperty('--left-top-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftBottomRef), dividerGap)}px`)
appContainer.style.setProperty('--left-bottom-panel-max-height', `${subSlotMaxHeight(leftColumnHeight, buttonHeight(leftTopRef), dividerGap)}px`)
appContainer.style.setProperty('--right-top-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightBottomRef), dividerGap)}px`)
appContainer.style.setProperty('--right-bottom-panel-max-height', `${subSlotMaxHeight(rightColumnHeight, buttonHeight(rightTopRef), dividerGap)}px`)
}

export function useLayoutMeasurements () {
const { dispatch, breakpoint, layoutRefs, arePluginsEvaluated, appVisible, isFullscreen } = useApp()
const { mapSize, isMapReady } = useMap()

const { bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef, actionsRef } = layoutRefs

// --------------------------------
// 2. Clear the evaluated flag when structural inputs change so the safe zone
// 1. Clear the evaluated flag when structural inputs change so the safe zone
// is not dispatched until useButtonStateEvaluator has completed a full
// pass with the new app/map state and set PLUGINS_EVALUATED.
// --------------------------------
Expand All @@ -133,7 +125,7 @@ export function useLayoutMeasurements () {
}, [breakpoint, mapSize, isMapReady, appVisible, isFullscreen])

// --------------------------------
// 3. Once all plugin button props have been evaluated (arePluginsEvaluated),
// 2. Once all plugin button props have been evaluated (arePluginsEvaluated),
// recalculate layout and dispatch the safe zone inset.
// RAF required to ensure browser layout is committed before measuring.
// --------------------------------
Expand All @@ -142,7 +134,7 @@ export function useLayoutMeasurements () {
return
}
requestAnimationFrame(() => {
calculateLayout()
calculateLayout(layoutRefs)
const safeZoneInset = getSafeZoneInset(layoutRefs)
if (safeZoneInset) {
dispatch({ type: 'SET_SAFE_ZONE_INSET', payload: { safeZoneInset } })
Expand All @@ -151,13 +143,13 @@ export function useLayoutMeasurements () {
}, [arePluginsEvaluated])

// --------------------------------
// 4. Recalculate CSS vars whenever observed elements resize (panels, banner,
// 3. Recalculate CSS vars whenever observed elements resize (panels, banner,
// actions buttons, etc.). Safe zone is intentionally not dispatched here —
// that is Effect 3's responsibility.
// that is Effect 2's responsibility.
// --------------------------------
useResizeObserver([bannerRef, mainRef, topRef, topLeftColRef, topRightColRef, actionsRef, bottomRef, bottomRightRef, leftTopRef, leftBottomRef, rightTopRef, rightBottomRef, drawerRef], () => {
requestAnimationFrame(() => {
calculateLayout()
calculateLayout(layoutRefs)
})
})
}
2 changes: 1 addition & 1 deletion src/App/layout/Layout.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const Layout = () => {
style={{ backgroundColor: mapStyle?.backgroundColor || undefined, ...getMapThemeVars(mapStyle) }}
ref={layoutRefs.appContainerRef}
>
<Viewport keyboardHintPortalRef={layoutRefs.topRef} />
<Viewport />
<div className={`im-o-app__overlay${isLayoutReady ? '' : ' im-o-app__overlay--not-ready'}`}>
<div className='im-o-app__side' ref={layoutRefs.sideRef}>
<SlotRenderer slot={layoutSlots.SIDE} />
Expand Down
8 changes: 0 additions & 8 deletions src/App/layout/layout.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -439,14 +439,6 @@
box-sizing: border-box;
}

// Hide containers when keyboard hint is visible
.im-o-app__main--keyboard-hint-visible {
.im-o-app__top-col,
.im-o-app__right,
.im-o-app__right-bottom {
opacity: 0;
}
}

// Avoid refresh jump if layout clacs are not ready
.im-o-app__overlay--not-ready {
Expand Down
Loading