Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/bright-portals-float.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tumaet/apollon": patch
---

Popovers, selects, tooltips, color pickers, and drag previews remain visible and interactive when an embedded editor enters fullscreen.
9 changes: 8 additions & 1 deletion library/lib/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ import {
} from "@/components/collaboration/CollaborationLayer"
import { TooltipProvider } from "@/components/ui"
import { EdgeGeometrySolver } from "@/components/EdgeGeometrySolver"
import {
ApollonPortalContainerProvider,
ApollonPortalRoot,
} from "@/components/ui/portalContainer"

interface AppProps {
onReactFlowInit: (instance: ReactFlowInstance) => void
Expand Down Expand Up @@ -271,6 +275,7 @@ function App({
<ScrollOverlay />
<CollaborationLayer options={collaboration} awareness={awareness} />
</div>
<ApollonPortalRoot />
</div>
</TooltipProvider>
)
Expand All @@ -279,7 +284,9 @@ function App({
export function AppWithProvider(props: AppProps) {
return (
<ReactFlowProvider>
<App {...props} />
<ApollonPortalContainerProvider>
<App {...props} />
</ApollonPortalContainerProvider>
</ReactFlowProvider>
)
}
4 changes: 3 additions & 1 deletion library/lib/components/DraggableGhost.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createPortal } from "react-dom"
import { useReactFlow, type XYPosition } from "@xyflow/react"
import { useMetadataStore } from "@/store/context"
import { resolveApollonThemeVars } from "@/components/ui/portalTheme"
import { useApollonPortalContainer } from "@/components/ui/portalContainer"
import { useShallow } from "zustand/shallow"
import { usePalettePlacement } from "@/hooks/usePalettePlacement"

Expand Down Expand Up @@ -63,6 +64,7 @@ export const DraggableGhost: React.FC<DraggableGhostProps> = ({
nodeTypeLabel: state.labels.nodeTypeLabel,
}))
)
const portalContainer = useApollonPortalContainer()

const [isDragging, setIsDragging] = useState(false)
const [ghostPosition, setGhostPosition] = useState({ x: 0, y: 0 })
Expand Down Expand Up @@ -281,7 +283,7 @@ export const DraggableGhost: React.FC<DraggableGhostProps> = ({
>
{children}
</button>
{isDragging && createPortal(ghostElement, document.body)}
{isDragging && createPortal(ghostElement, portalContainer)}
</>
)
}
4 changes: 3 additions & 1 deletion library/lib/components/popovers/GenericPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { ReactNode } from "react"
import { Popover } from "@base-ui/react/popover"
import { PopoverOrigin } from "@/types"
import { usePortalThemeVars } from "@/components/ui/portalTheme"
import { useApollonPortalContainer } from "@/components/ui/portalContainer"

interface GenericPopoverProps {
id: string
Expand Down Expand Up @@ -54,6 +55,7 @@ export const GenericPopover: React.FC<GenericPopoverProps> = ({
const popoverThemeVars = usePortalThemeVars(
anchorEl instanceof Element ? anchorEl : null
)
const portalContainer = useApollonPortalContainer()

const { side, align } = toSideAlign(transformOrigin)

Expand All @@ -66,7 +68,7 @@ export const GenericPopover: React.FC<GenericPopoverProps> = ({
if (!next) onClose()
}}
>
<Popover.Portal>
<Popover.Portal container={portalContainer}>
{anchorEl && (
<Popover.Positioner
anchor={anchorEl}
Expand Down
4 changes: 3 additions & 1 deletion library/lib/components/popovers/TagPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Check, Plus, Tag, X } from "lucide-react"
import { Popover } from "@base-ui/react/popover"
import { IconButton, Tooltip } from "@/components/ui"
import { usePortalThemeVars } from "@/components/ui/portalTheme"
import { useApollonPortalContainer } from "@/components/ui/portalContainer"
import { useLabels } from "@/i18n/useLabels"
import { useTagConfig } from "@/hooks/useTagConfig"
import { normalizeTags } from "@/utils"
Expand Down Expand Up @@ -68,6 +69,7 @@ export const TagPicker: React.FC<TagControlProps> = ({
const [draft, setDraft] = useState("")
const [trigger, setTrigger] = useState<HTMLElement | null>(null)
const portalThemeVars = usePortalThemeVars(trigger)
const portalContainer = useApollonPortalContainer()

if (!enabled) return null

Expand Down Expand Up @@ -105,7 +107,7 @@ export const TagPicker: React.FC<TagControlProps> = ({
<Tag width={16} height={16} aria-hidden="true" />
</Popover.Trigger>
</Tooltip>
<Popover.Portal>
<Popover.Portal container={portalContainer}>
<Popover.Positioner sideOffset={6} align="start">
<Popover.Popup
data-slot="tag-picker-content"
Expand Down
4 changes: 3 additions & 1 deletion library/lib/components/styleEditor/ColorButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
SWATCH_NAMES,
} from "@tumaet/ui/lib/color-swatch-tokens"
import { usePortalThemeVars } from "@/components/ui/portalTheme"
import { useApollonPortalContainer } from "@/components/ui/portalContainer"
import { useLabels } from "@/i18n/useLabels"

// Embed-safe editor color-picker. Mirrors the @tumaet/ui color-picker STRUCTURE
Expand Down Expand Up @@ -56,6 +57,7 @@ export const EditorColorPicker: React.FC<EditorColorPickerProps> = ({
// onto it so a dark or custom embed theme paints the picker.
const [trigger, setTrigger] = React.useState<HTMLElement | null>(null)
const portalThemeVars = usePortalThemeVars(trigger)
const portalContainer = useApollonPortalContainer()

const isCustom =
selectedColor !== "" &&
Expand All @@ -82,7 +84,7 @@ export const EditorColorPicker: React.FC<EditorColorPickerProps> = ({
} as React.CSSProperties
}
/>
<Popover.Portal>
<Popover.Portal container={portalContainer}>
<Popover.Positioner sideOffset={6} align="start">
<Popover.Popup
data-slot="color-picker-content"
Expand Down
4 changes: 3 additions & 1 deletion library/lib/components/styleEditor/StyleEditorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Popover } from "@base-ui/react/popover"
import { DividerLine, Tooltip, Typography } from "@/components/ui"
import { EditorColorPicker } from "./ColorButtons"
import { usePortalThemeVars } from "@/components/ui/portalTheme"
import { useApollonPortalContainer } from "@/components/ui/portalContainer"
import { useLabels } from "@/i18n/useLabels"

/**
Expand Down Expand Up @@ -51,6 +52,7 @@ export function StyleEditorPanel<K extends string>({
// picker) so a dark/custom embed theme paints the panel.
const [trigger, setTrigger] = useState<HTMLElement | null>(null)
const portalThemeVars = usePortalThemeVars(trigger)
const portalContainer = useApollonPortalContainer()
const paintToggleLabel = colorEditorActionLabel ?? t.editColors

return (
Expand All @@ -75,7 +77,7 @@ export function StyleEditorPanel<K extends string>({
<PaintRoller width={16} height={16} aria-hidden="true" />
</Popover.Trigger>
</Tooltip>
<Popover.Portal>
<Popover.Portal container={portalContainer}>
<Popover.Positioner sideOffset={6} align="end">
<Popover.Popup
data-slot="style-editor-content"
Expand Down
4 changes: 3 additions & 1 deletion library/lib/components/ui/Select.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useId, useRef, useState } from "react"
import { Popover } from "@base-ui/react/popover"
import { ChevronDown } from "lucide-react"
import { useApollonPortalContainer } from "./portalContainer"
import { usePortalThemeVars } from "./portalTheme"
import { useLabels } from "@/i18n/useLabels"

Expand Down Expand Up @@ -59,6 +60,7 @@ export const Select: React.FC<SelectProps> = ({
// scopes `--apollon-*`; carry the resolved theme onto the popup so a dark or
// custom embed theme paints the open menu.
const portalThemeVars = usePortalThemeVars(trigger)
const portalContainer = useApollonPortalContainer()

const selected = options.find((o) => o.value === value)

Expand Down Expand Up @@ -180,7 +182,7 @@ export const Select: React.FC<SelectProps> = ({
</button>
}
/>
<Popover.Portal>
<Popover.Portal container={portalContainer}>
<Popover.Positioner align="start" sideOffset={4} collisionPadding={8}>
<Popover.Popup
initialFocus={false}
Expand Down
8 changes: 7 additions & 1 deletion library/lib/components/ui/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
TooltipContent,
TooltipProvider as SharedTooltipProvider,
} from "@tumaet/ui/components/tooltip"
import { useApollonPortalContainer } from "./portalContainer"
import { usePortalThemeVars } from "./portalTheme"

// Wraps the shared @tumaet/ui Tooltip so the editor renders the same primitive as
Expand Down Expand Up @@ -41,6 +42,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
const [triggerElement, setTriggerElement] =
React.useState<HTMLButtonElement | null>(null)
const portalThemeVars = usePortalThemeVars(triggerElement)
const portalContainer = useApollonPortalContainer()

if (!title) return <>{children}</>

Expand All @@ -59,7 +61,11 @@ export const Tooltip: React.FC<TooltipProps> = ({
return (
<SharedTooltip>
{trigger}
<TooltipContent side={side} style={portalThemeVars}>
<TooltipContent
side={side}
style={portalThemeVars}
portalContainer={portalContainer}
>
{title}
</TooltipContent>
</SharedTooltip>
Expand Down
101 changes: 101 additions & 0 deletions library/lib/components/ui/portalContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
createContext,
use,
useMemo,
useState,
useSyncExternalStore,
type ReactNode,
} from "react"

interface PortalContainerContextValue {
portalContainer: HTMLElement | null
setPortalContainer: (container: HTMLDivElement | null) => void
}

const ApollonPortalContainerContext =
createContext<PortalContainerContextValue | null>(null)

const fullscreenListeners = new Set<() => void>()
const notifyFullscreenListeners = () => {
for (const listener of fullscreenListeners) listener()
}

function subscribeToFullscreen(listener: () => void): () => void {
fullscreenListeners.add(listener)
if (fullscreenListeners.size === 1) {
document.addEventListener("fullscreenchange", notifyFullscreenListeners)
}
return () => {
fullscreenListeners.delete(listener)
if (fullscreenListeners.size === 0) {
document.removeEventListener(
"fullscreenchange",
notifyFullscreenListeners
)
}
}
}

/**
* Owns one layout-neutral portal destination for a single editor instance.
* Keeping the root in React's tree makes its lifetime match the editor and
* prevents fullscreen in one editor from capturing another editor's surfaces.
*/
export function ApollonPortalContainerProvider({
children,
}: {
children: ReactNode
}) {
const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(
null
)
const context = useMemo(
() => ({ portalContainer, setPortalContainer }),
[portalContainer]
)

return (
<ApollonPortalContainerContext value={context}>
{children}
</ApollonPortalContainerContext>
)
}

/** Declarative root rendered inside the editor's own stacking context. */
export function ApollonPortalRoot() {
const context = use(ApollonPortalContainerContext)
return (
<div
ref={context?.setPortalContainer}
className="apollon-editor__portal-root"
data-apollon-portal-root=""
/>
)
}

/**
* Returns body whenever it remains in the browser's fullscreen subtree. For
* element-level fullscreen that excludes body, it returns this editor's local
* root instead. The subscription re-portals already-open surfaces when
* fullscreen changes.
*/
export function useApollonPortalContainer(): HTMLElement {
const portalContainer =
use(ApollonPortalContainerContext)?.portalContainer ?? null
const fullscreenElement = useSyncExternalStore(
subscribeToFullscreen,
() => document.fullscreenElement ?? null,
() => null
)

const bodyExcludedFromFullscreen =
fullscreenElement != null && !fullscreenElement.contains(document.body)
if (
portalContainer &&
bodyExcludedFromFullscreen &&
fullscreenElement.contains(portalContainer)
) {
return portalContainer
}
return document.body
}
14 changes: 14 additions & 0 deletions library/lib/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,20 @@
z-index: var(--apollon-z-chrome);
}

/* Each editor owns a layout-neutral portal root. Floating surfaces use it only
while this editor participates in fullscreen, keeping them in the browser's
fullscreen subtree without escaping the editor's host stacking context. */
.apollon-editor__portal-root {
position: absolute;
z-index: var(--apollon-z-chrome);
inset: 0;
pointer-events: none;
}

.apollon-editor__portal-root > * {
pointer-events: auto;
}

/* The chrome frame. A CSS grid over the full-bleed canvas: the header/footer own
the top/bottom rows, rails span the side tracks between those bands, corner
slots float over the side/center tracks, and the centre cell is the canvas
Expand Down
Loading
Loading