Skip to content

Commit

Permalink
feat: add Slot control
Browse files Browse the repository at this point in the history
  • Loading branch information
migueloller committed Sep 14, 2022
1 parent cf7f911 commit 705568a
Show file tree
Hide file tree
Showing 11 changed files with 330 additions and 56 deletions.
2 changes: 1 addition & 1 deletion packages/runtime/src/box-model.ts
@@ -1,2 +1,2 @@
export { createBox, getBox, parse } from './state/modules/box-models'
export type { BoxModelHandle } from './state/modules/box-models'
export type { BoxModelHandle, BoxModel } from './state/modules/box-models'
1 change: 1 addition & 0 deletions packages/runtime/src/controls/index.ts
Expand Up @@ -8,6 +8,7 @@ export * from './list'
export * from './number'
export * from './select'
export * from './shape'
export * from './slot'
export * from './style'
export * from './text-area'
export * from './text-input'
52 changes: 52 additions & 0 deletions packages/runtime/src/controls/slot.ts
@@ -0,0 +1,52 @@
import { PropController } from '../prop-controllers/instances'
import { BoxModel } from '../state/modules/box-models'
import { Element } from '../state/react-page'
import { ResponsiveValue } from './types'

type SlotControlColumnData = { count: number; spans: number[][] }

export type SlotControlData = {
elements: Element[]
columns: ResponsiveValue<SlotControlColumnData>
}

export const SlotControlType = 'makeswift::controls::slot'

export type SlotControlDefinition = {
type: typeof SlotControlType
}

export function Slot(): SlotControlDefinition {
return { type: SlotControlType }
}

export const SlotControlMessageType = {
CONTAINER_BOX_MODEL_CHANGE: 'makeswift::controls::slot::message::container-box-model-change',
ITEM_BOX_MODEL_CHANGE: 'makeswift::controls::slot::message::item-box-model-change',
} as const

type SlotControlContainerBoxModelChangeMessage = {
type: typeof SlotControlMessageType.CONTAINER_BOX_MODEL_CHANGE
payload: { boxModel: BoxModel }
}

type SlotControlItemBoxModelChangeMessage = {
type: typeof SlotControlMessageType.ITEM_BOX_MODEL_CHANGE
payload: { index: number; boxModel: BoxModel }
}

export type SlotControlMessage =
| SlotControlContainerBoxModelChangeMessage
| SlotControlItemBoxModelChangeMessage

export class SlotControl extends PropController<SlotControlMessage> {
recv(): void {}

changeContainerBoxModel(boxModel: BoxModel): void {
this.send({ type: SlotControlMessageType.CONTAINER_BOX_MODEL_CHANGE, payload: { boxModel } })
}

changeItemBoxModel(index: number, boxModel: BoxModel): void {
this.send({ type: SlotControlMessageType.ITEM_BOX_MODEL_CHANGE, payload: { index, boxModel } })
}
}
2 changes: 2 additions & 0 deletions packages/runtime/src/prop-controllers/descriptors.ts
Expand Up @@ -15,6 +15,7 @@ import {
ListControlDefinition,
SelectControlDefinition,
ShapeControlDefinition,
SlotControlDefinition,
TextAreaControlDefinition,
TextInputControlDefinition,
} from '../controls'
Expand Down Expand Up @@ -964,6 +965,7 @@ export type Descriptor<T extends Data = Data> =
| ShapeControlDefinition
| ListControlDefinition
| LinkControlDefinition
| SlotControlDefinition

export type PanelDescriptorType =
| typeof Types.Backgrounds
Expand Down
12 changes: 10 additions & 2 deletions packages/runtime/src/prop-controllers/instances.ts
Expand Up @@ -4,6 +4,7 @@ import { OnChangeParam } from 'slate-react'
import { Descriptor, RichTextDescriptor, TableFormFieldsDescriptor, Types } from './descriptors'
import { BuilderEditMode } from '../utils/constants'
import { BoxModel } from '../state/modules/box-models'
import { SlotControl, SlotControlMessage, SlotControlType } from '../controls'

export const RichTextPropControllerMessageType = {
CHANGE_BUILDER_EDIT_MODE: 'CHANGE_BUILDER_EDIT_MODE',
Expand Down Expand Up @@ -47,9 +48,12 @@ export type RichTextPropControllerMessage =
| UndoRichTextPropControllerMessage
| RedoRichTextPropControllerMessage

export type PropControllerMessage = RichTextPropControllerMessage | TableFormFieldsMessage
export type PropControllerMessage =
| RichTextPropControllerMessage
| TableFormFieldsMessage
| SlotControlMessage

type Send<T = PropControllerMessage> = (message: T) => void
export type Send<T = PropControllerMessage> = (message: T) => void

export abstract class PropController<T = PropControllerMessage> {
protected send: Send<T>
Expand Down Expand Up @@ -168,6 +172,7 @@ type AnyPropController =
| DefaultPropController
| RichTextPropController
| TableFormFieldsPropController
| SlotControl

export function createPropController(
descriptor: RichTextDescriptor,
Expand All @@ -189,6 +194,9 @@ export function createPropController<T extends PropControllerMessage>(
case Types.TableFormFields:
return new TableFormFieldsPropController(send as Send<TableFormFieldsMessage>)

case SlotControlType:
return new SlotControl(send as Send<SlotControlMessage>)

default:
return new DefaultPropController(send as Send)
}
Expand Down
26 changes: 25 additions & 1 deletion packages/runtime/src/runtimes/react/controls.tsx
@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react'

import { useStore } from '.'
import { useDocumentKey, useSelector, useStore } from '.'
import * as ReactPage from '../../state/react-page'
import { Props } from '../../prop-controllers'
import {
Expand All @@ -26,13 +26,16 @@ import {
NumberControlType,
SelectControlType,
ShapeControlType,
SlotControl,
SlotControlType,
StyleControlType,
TextAreaControlType,
TextInputControlType,
} from '../../controls'
import { useFormattedStyle } from './controls/style'
import { ControlValue } from './controls/control'
import { RenderHook } from './components'
import { useSlot } from './controls/slot'

export type ResponsiveColor = ResponsiveValue<ColorValue>

Expand Down Expand Up @@ -84,6 +87,13 @@ export function PropsValue({ element, children }: PropsValueProps): JSX.Element
ReactPage.getComponentPropControllerDescriptors(store.getState(), element.type) ?? {},
)
const props = element.props as Record<string, any>
const documentKey = useDocumentKey()

const propControllers = useSelector(state => {
if (documentKey == null) return null

return ReactPage.getPropControllers(state, documentKey, element.key)
})

return Object.entries(propControllerDescriptorsRef.current).reduceRight(
(renderFn, [propName, descriptor]) =>
Expand Down Expand Up @@ -117,6 +127,20 @@ export function PropsValue({ element, children }: PropsValueProps): JSX.Element
</RenderHook>
)

case SlotControlType: {
const control = (propControllers?.[propName] ?? null) as SlotControl | null

return (
<RenderHook
key={descriptor.type}
hook={useSlot}
parameters={[props[propName], control]}
>
{value => renderFn({ ...propsValue, [propName]: value })}
</RenderHook>
)
}

case Props.Types.Width:
return (
<RenderHook
Expand Down
195 changes: 195 additions & 0 deletions packages/runtime/src/runtimes/react/controls/slot.tsx
@@ -0,0 +1,195 @@
import { ComponentPropsWithoutRef, ElementType, ReactNode, useEffect, useState } from 'react'
import { SlotControl, SlotControlData } from '../../../controls'

import { BoxModel, getBox } from '../../../state/modules/box-models'
import deepEqual from '../../../utils/deepEqual'
import { Element } from '../../../runtimes/react'
import { getIndexes } from '../../../components/utils/columns'
import { responsiveStyle } from '../../../components/utils/responsive-style'
import { useStyle } from '../use-style'
import { cx } from '@emotion/css'

export type SlotControlValue = ReactNode

export function useSlot(data: SlotControlData, control: SlotControl | null) {
// TODO(miguel): While the UI shouldn't allow the state, we should probably check that at least
// one element is visible.
if (data == null || data.elements.length === 0) {
return <Slot.Placeholder control={control} />
}

return (
<Slot control={control}>
{data.elements.map((element, i) => (
<Slot.Item key={element.key} control={control} grid={data.columns} index={i}>
<Element element={element} />
</Slot.Item>
))}
</Slot>
)
}

type SlotProps<T extends ElementType> = {
as?: T
control: SlotControl | null
children?: ReactNode
className?: string
}

export function Slot<T extends ElementType = 'div'>({
as,
control,
children,
className,
...restOfProps
}: SlotProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof SlotProps<T>>) {
const As = as ?? 'div'
const [element, setElement] = useState<Element | null>(null)
const baseClassName = useStyle({
display: 'flex',
flexWrap: 'wrap',
width: '100%',
})

useEffect(() => {
if (element == null || control == null) return

return pollBoxModel({
element,
onBoxModelChange: boxModel => control.changeContainerBoxModel(boxModel),
})
}, [element, control])

return (
<As {...restOfProps} ref={setElement} className={cx(baseClassName, className)}>
{children}
</As>
)
}

Slot.Placeholder = SlotPlaceholder

Slot.Item = SlotItem

type SlotItemProps<T extends ElementType> = {
as?: T
control: SlotControl | null
grid: SlotControlData['columns']
index: number
children?: ReactNode
className?: string
}

function SlotItem<T extends ElementType = 'div'>({
as,
control,
grid,
index,
children,
className,
...restOfProps
}: SlotItemProps<T> & Omit<ComponentPropsWithoutRef<T>, keyof SlotItemProps<T>>): JSX.Element {
const As = as ?? 'div'
const [element, setElement] = useState<Element | null>(null)
const baseClassName = useStyle({
display: 'flex',
...responsiveStyle([grid], ([{ count = 12, spans = [[12]] } = {}]) => {
const [rowIndex, columnIndex] = getIndexes(spans, index)
const span = spans[rowIndex][columnIndex]
const flexBasis = `calc(100% * ${(span / count).toFixed(5)})`

return span === 0 ? { display: 'none' } : { flexBasis, minWidth: flexBasis }
}),
})

useEffect(() => {
if (element == null || control == null) return

return pollBoxModel({
element,
onBoxModelChange: boxModel => control.changeItemBoxModel(index, boxModel),
})
}, [element, control, index])

return (
<As {...restOfProps} ref={setElement} className={cx(baseClassName, className)}>
{children}
</As>
)
}

type SlotPlaceholderProps = {
control: SlotControl | null
}

function SlotPlaceholder({ control }: SlotPlaceholderProps): JSX.Element {
const [element, setElement] = useState<Element | null>(null)

useEffect(() => {
if (element == null || control == null) return

return pollBoxModel({
element,
onBoxModelChange: boxModel => control.changeContainerBoxModel(boxModel),
})
}, [element, control])

return (
<div
ref={setElement}
className={useStyle({
width: '100%',
background: 'rgba(161, 168, 194, 0.18)',
height: '80px',
})}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
className={useStyle({ overflow: 'visible', padding: 8 })}
>
<rect
x={0}
y={0}
width="100%"
height="100%"
strokeWidth={2}
strokeDasharray="4 2"
fill="none"
stroke="rgba(161, 168, 194, 0.40)"
rx="4"
ry="4"
/>
</svg>
</div>
)
}

function pollBoxModel({
element,
onBoxModelChange,
}: {
element: Element
onBoxModelChange(boxModel: BoxModel): void
}): () => void {
let currentBoxModel: BoxModel | null = null

const handleAnimationFrameRequest = () => {
const measuredBoxModel = getBox(element)

if (!deepEqual(currentBoxModel, measuredBoxModel)) {
currentBoxModel = measuredBoxModel

onBoxModelChange(currentBoxModel)
}

animationFrameHandle = requestAnimationFrame(handleAnimationFrameRequest)
}

let animationFrameHandle = requestAnimationFrame(handleAnimationFrameRequest)

return () => {
cancelAnimationFrame(animationFrameHandle)
}
}
4 changes: 2 additions & 2 deletions packages/runtime/src/runtimes/react/index.tsx
Expand Up @@ -153,7 +153,7 @@ export function PageProvider({ id, children }: PageProviderProps) {

const DocumentContext = createContext<string | null>(null)

function useDocumentKey(): string | null {
export function useDocumentKey(): string | null {
return useContext(DocumentContext)
}

Expand All @@ -163,7 +163,7 @@ export function useStore(): ReactPage.Store {
return useContext(Context)
}

function useSelector<R>(selector: (state: State) => R): R {
export function useSelector<R>(selector: (state: State) => R): R {
const store = useStore()

return useSyncExternalStoreWithSelector(store.subscribe, store.getState, store.getState, selector)
Expand Down

0 comments on commit 705568a

Please sign in to comment.