import { Children, ComponentType, Fragment, ReactElement, ReactNode, isValidElement, } from "react" import { LayoutEntry } from "./entry" import type { LayoutEntryProps } from "./entry" import { LayoutPreference } from "./types" // Both core entries and widgets carry a `render` thunk so the rest of the // composer treats them uniformly: core entries return their prebuilt element // (ignoring `data`), widgets render their component with the page `data`. The // thunk is what keeps `data` out of the layout-model memo — rendering is // deferred to call time rather than baked into the entry. export type EntryRenderer = (data: unknown) => ReactNode export type DisplayEntry = { widgetId: string render: EntryRenderer order: number hidden: boolean } export type RawEntry = { widgetId: string render: EntryRenderer naturalSection: string } /** Derives a stable string identifier from a React element's component type. */ function getElementName(element: ReactElement): string { const { type } = element if (typeof type === "string") { return type } return ( (type as ComponentType).displayName ?? (type as ComponentType).name ?? "unknown" ) } function isLayoutEntry( element: ReactElement ): element is ReactElement { return element.type === LayoutEntry } /** * Resolves the identity segment for a core element. Prefers an explicit * `LayoutComposer.Entry` wrapper id (stable across renames and minification); * otherwise falls back to the component's display/function name. */ function getCoreEntryKey(element: ReactElement): string { if (isLayoutEntry(element)) { return element.props.id } return getElementName(element) } /** * Builds a core entry for every element across all sections, in source order. * Each entry renders its own prebuilt element. * * Duplicate ids (same component/name, or two anonymous elements) are * disambiguated with a `#n` suffix via a single `seen` map shared across all * sections — so two same-named entries in different sections still get distinct, * deterministic ids and don't collide on keys, drag ids, or preference lookups. * The id intentionally omits the section, so an entry keeps its identity (and * saved preference) when its natural section changes in code. */ export function buildCoreEntries( elementsBySection: Record ): RawEntry[] { const entries: RawEntry[] = [] const seen = new Map() for (const [sectionName, elements] of Object.entries(elementsBySection)) { for (const el of elements) { const name = getCoreEntryKey(el) const base = `core:${name}` const count = seen.get(base) ?? 0 seen.set(base, count + 1) const widgetId = count === 0 ? base : `${base}#${count + 1}` entries.push({ widgetId, render: () => el, naturalSection: sectionName, }) } } return entries } export function extractSectionElements( sections: Record ): Record { const result: Record = {} for (const [sectionName, node] of Object.entries(sections)) { result[sectionName] = collectElements(node) } return result } /** * Flattens a section's children into individual ReactElements, unwrapping * fragments so each direct child component becomes its own customizer entry. */ function collectElements(node: ReactNode): ReactElement[] { const elements: ReactElement[] = [] Children.forEach(node, (child) => { if (!isValidElement(child)) { return } if (child.type === Fragment) { const fragmentChildren = (child.props as { children?: ReactNode }) .children elements.push(...collectElements(fragmentChildren)) return } elements.push(child) }) return elements } /** * Merges all entries (core + widgets) and applies a preference, returning a * map of effective-section → DisplayEntry[] sorted by order. Includes hidden * entries (with `hidden: true`) so the renderer can ghost them in edit mode. * * `validSections` is the set of section ids the active layout actually renders. * A stored `section` override that points at a section which no longer exists * (layout switched, section renamed/removed) is ignored so the entry falls back * to its natural section instead of vanishing into an unrendered bucket. */ export function buildDisplayEntries( raw: RawEntry[], preference: LayoutPreference, validSections: Set ): Record { const result: Record = {} for (const entry of raw) { const pref = preference.widgets[entry.widgetId] const overrideSection = pref?.section && validSections.has(pref.section) ? pref.section : undefined const effectiveSection = overrideSection ?? entry.naturalSection // Entries with no saved order share order 0; their relative placement // (widgets before core, each group in source order) comes from insertion // order preserved by the stable sort below. const effectiveOrder = pref?.order ?? 0 const hidden = pref?.hidden ?? false if (!result[effectiveSection]) { result[effectiveSection] = [] } result[effectiveSection].push({ widgetId: entry.widgetId, render: entry.render, order: effectiveOrder, hidden, }) } for (const k of Object.keys(result)) { result[k].sort((a, b) => a.order - b.order) } return result } export const SECTION_TAIL_SUFFIX = "::tail" export const isSectionTailId = (id: string) => id.endsWith(SECTION_TAIL_SUFFIX) export const getSectionIdFromTailId = (id: string) => id.slice(0, -SECTION_TAIL_SUFFIX.length) // A section's trailing drop zone gets its own droppable id, derived from the // section id, so the drag handlers can tell "dropped at the end of a section" // apart from "dropped onto a specific entry". Kept here next to the component // that renders it. export const getSectionTailId = (sectionId: string) => `${sectionId}${SECTION_TAIL_SUFFIX}`