Skip to content

Commit

Permalink
[base/presence] Make a local rect type and make placeholder forward ref
Browse files Browse the repository at this point in the history
  • Loading branch information
bjoerge authored and rexxars committed Oct 6, 2020
1 parent 2623d0c commit 15d4c1b
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 122 deletions.
Original file line number Diff line number Diff line change
@@ -1,45 +1,46 @@
import * as React from 'react'
import {OverlayReporterContext} from './types'
import {DelegateComponentType, OverlayReporterContext} from './types'

export const createReporter = (Context: React.Context<OverlayReporterContext>) =>
React.memo(function RegionReporter<Data>(props: {
// todo: fix wonky typings
id: string
data: Data
children?: React.ReactNode
component: React.ComponentType<Data>
component?: DelegateComponentType<Data>
style?: React.CSSProperties
className?: string
}) {
const {id, component, data} = props
const ref = React.useRef<HTMLDivElement>()
const {id, component = 'div', data, ...rest} = props
const ref = React.useRef()
const context = React.useContext(Context)

React.useEffect(() => {
context.dispatch({
type: 'mount',
type: 'update',
id,
element: ref.current,
data,
component
})
return () => {
context.dispatch({type: 'unmount', id})
}
}, [])
}, [props])

React.useEffect(() => {
context.dispatch({
type: 'update',
type: 'mount',
id,
element: ref.current,
data,
component
})
}, [props])
return () => {
context.dispatch({type: 'unmount', id})
}
}, [])

const Component = component
return (
// note the wrapper here must be a block element for ResizeObserver to work properly
<div ref={ref} style={{visibility: 'hidden', ...props.style}}>
<Component {...data} />
</div>
)
return React.createElement(component, {
ref,
style: props.style,
...data,
...rest
})
})
Original file line number Diff line number Diff line change
@@ -1,60 +1,39 @@
import * as React from 'react'
import {merge, ReplaySubject, Subject} from 'rxjs'
import {concat, merge, of, ReplaySubject, Subject} from 'rxjs'
import {
debounceTime,
distinctUntilChanged,
filter,
map,
mergeMap,
publishReplay,
refCount,
scan,
share,
takeUntil,
tap
} from 'rxjs/operators'
import {createResizeObserver, ObservableResizeObserver} from './resizeObserver'
import {
ReportedRegion,
Rect,
OverlayReporterContext,
RegionReporterEvent,
RegionReporterUpdateEvent,
RegionReporterMountEvent,
RegionReporterUnmountEvent
RegionReporterUnmountEvent,
RegionReporterUpdateEvent,
ReportedRegion
} from './types'
import {createReporter} from './createReporter'

function isId<T extends {id: string}>(id: string) {
return (event: T) => event.id === id
}

const getOffsetsTo = (source, target) => {
let el = source
let top = 0
let left = 0
while (el && el !== target) {
top += el.offsetTop
left += el.offsetLeft
el = el.offsetParent
}
return {top, left}
}

function getRelativeRect(element, parent): Rect {
return {
...getOffsetsTo(element, parent),
width: element.offsetWidth,
height: element.offsetHeight
}
}

export type TrackerComponentProps<ComponentProps, RegionData> = ComponentProps & {
regions: ReportedRegion<RegionData>[]
children: React.ReactNode
trackerRef: React.RefObject<HTMLElement | null>
}

export type TrackerProps<ComponentProps, RegionData = {}> = {
export type TrackerProps<ComponentProps = {}, RegionData = {}> = {
component: React.ComponentType<TrackerComponentProps<ComponentProps, RegionData>>
children: React.ReactNode
style?: React.CSSProperties
Expand All @@ -73,7 +52,7 @@ export function createTracker() {
props: TrackerProps<ComponentProps, RegionData>
) {
const trackerRef = React.useRef<HTMLElement>()
const [items, setItems] = React.useState([])
const [regions, setRegions] = React.useState<ReportedRegion<RegionData>[]>([])

const regionReporterElementEvents$: Subject<RegionReporterEvent> = React.useMemo(
() => new ReplaySubject<RegionReporterEvent>(),
Expand All @@ -97,72 +76,68 @@ export function createTracker() {
filter((ev): ev is RegionReporterUnmountEvent => ev.type === 'unmount')
)

const positions$ = mounts$.pipe(
const regions$ = mounts$.pipe(
mergeMap((mountEvent: RegionReporterMountEvent, i) => {
const elementId = mountEvent.id
const unmounted$ = unmounts$.pipe(filter(isId(elementId)), share())
const elementUpdates$ = updates$.pipe(filter(isId(elementId)), share())

return merge(
trackerBounds$.pipe(
map(() => ({
type: 'update',
id: elementId,
rect: getRelativeRect(mountEvent.element, trackerRef.current)
}))
),
elementUpdates$.pipe(
map(update => ({
type: 'update',
id: elementId,
data: update.data,
component: update.component,
rect: getRelativeRect(mountEvent.element, trackerRef.current)
}))
),
unmounted$.pipe(
map(() => ({type: 'remove', id: elementId, children: null, rect: null}))
)
return concat(
of({
type: 'add' as const,
id: elementId,
element: mountEvent.element,
data: mountEvent.data
}),
merge(
trackerBounds$.pipe(
map(() => ({
type: 'update' as const,
id: elementId
}))
),
elementUpdates$.pipe(
map(update => ({
type: 'update' as const,
id: elementId,
data: update.data,
component: update.component
}))
)
).pipe(takeUntil(unmounted$)),
of({type: 'remove' as const, id: elementId})
)
}),
scan((items, event: any) => {
scan((items, event) => {
if (event.type === 'add') {
if (items.has(event.id)) {
throw new Error(`Integrity check failed: Region with id "${event.id}" already exists`)
}
items.set(event.id, {id: event.id, element: event.element, data: event.data})
}
if (event.type === 'update') {
const exists = items.some(item => item.id === event.id)
if (exists) {
return items.map(item =>
item.id === event.id
? {
id: event.id,
data: event.data || item.data,
component: event.component || item.component,
rect: event.rect || item.rect
}
: item
)
const existing = items.get(event.id)
if (!existing) {
throw new Error(`Integrity check failed: Region with id "${event.id}" is not known`)
}
return items.concat({
id: event.id,
rect: event.rect,
data: event.data,
component: event.component
})
items.set(event.id, {...existing, ...event})
}

if (event.type === 'remove') {
// todo: it would be better to keep track of elements a little while after their elements actually
// unmounts. this will make it possible to support fade out transitions and also animate components
// where the react reconciliation decides the most effective thing to do is to unmount and remount the
// component
// return items
return items.filter(item => item.id !== event.id)
if (!items.has(event.id)) {
throw new Error(`Integrity check failed: Region with id "${event.id}" is not known`)
}
items.delete(event.id)
}
return items
}, []),
map(items => items.filter(item => item.rect)),
distinctUntilChanged(),
debounceTime(100)
}, new Map<string, ReportedRegion<RegionData>>())
// debounceTime()
)
const sub = positions$.pipe(tap(setItems)).subscribe()
const sub = regions$
.pipe(
map(items => Array.from(items.values())),
tap(setRegions)
)
.subscribe()
return () => sub.unsubscribe()
}, [])

Expand All @@ -173,7 +148,7 @@ export function createTracker() {
const {component: Component, componentProps} = props
return (
<Context.Provider value={{dispatch}}>
<Component {...componentProps} regions={items} trackerRef={trackerRef}>
<Component {...componentProps} regions={regions} trackerRef={trackerRef}>
{props.children}
</Component>
</Context.Provider>
Expand Down
18 changes: 4 additions & 14 deletions packages/@sanity/base/src/components/react-track-elements/types.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import * as React from 'react'

export interface Rect {
height: number
width: number
top: number
left: number
}
export type DelegateComponentType<Data> = React.ComponentType<Data> | keyof React.ReactHTML

export interface ReportedRegion<RegionData> {
id: string
children?: React.ReactNode
rect: Rect
element: HTMLElement
data: RegionData
}

Expand All @@ -19,7 +14,7 @@ export interface RegionReporterMountEvent {
id: string
element: HTMLElement
data: any
component: React.ComponentType
component: React.ComponentType | keyof React.ReactHTML
}

export interface RegionReporterUnmountEvent {
Expand All @@ -31,19 +26,14 @@ export interface RegionReporterUpdateEvent {
type: 'update'
id: string
data: any
component: React.ComponentType
component: React.ComponentType | keyof React.ReactHTML
}

export type RegionReporterEvent =
| RegionReporterMountEvent
| RegionReporterUpdateEvent
| RegionReporterUnmountEvent

export interface Position {
id: string
rect: Rect
}

export interface OverlayReporterContext {
dispatch: (event: RegionReporterEvent) => void
}
12 changes: 8 additions & 4 deletions packages/@sanity/base/src/presence/FieldPresence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ export interface FieldPresenceProps {
maxAvatars: number
}

function FieldPresencePlaceholder(props: FieldPresenceProps) {
const minWidth = -AVATAR_DISTANCE + (AVATAR_SIZE + AVATAR_DISTANCE) * props.maxAvatars
return <div className={styles.root} style={{minWidth: minWidth, minHeight: AVATAR_SIZE}} />
}
const FieldPresencePlaceholder = React.forwardRef<HTMLDivElement, FieldPresenceProps>(
function FieldPresencePlaceholder(props: FieldPresenceProps, ref) {
const minWidth = -AVATAR_DISTANCE + (AVATAR_SIZE + AVATAR_DISTANCE) * props.maxAvatars
return (
<div ref={ref} className={styles.root} style={{minWidth: minWidth, minHeight: AVATAR_SIZE}} />
)
}
)

function FieldPresenceWithOverlay(props: FieldPresenceProps) {
const contextPresence = useContext(FormFieldPresenceContext)
Expand Down
39 changes: 36 additions & 3 deletions packages/@sanity/base/src/presence/overlay/StickyOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,13 @@ import {
SLIDE_RIGHT_THRESHOLD_BOTTOM,
SLIDE_RIGHT_THRESHOLD_TOP
} from '../constants'
import {FormFieldPresence, RegionWithIntersectionDetails} from '../types'
import {
FormFieldPresence,
Rect,
RegionsWithComputedRects,
RegionWithIntersectionDetails,
ReportedRegion
} from '../types'
import {FieldPresenceInner} from '../FieldPresence'
import {TrackerComponentProps} from '../../components/react-track-elements'
import {RegionsWithIntersections} from './RegionsWithIntersections'
Expand Down Expand Up @@ -102,9 +108,36 @@ type Props = TrackerComponentProps<{margins: Margins}, {presence: FormFieldPrese

const DEFAULT_MARGINS: Margins = [0, 0, 0, 0]

export function StickyOverlay(props: Props) {
const {regions, children, trackerRef, margins = DEFAULT_MARGINS} = props
const getOffsetsTo = (source, target) => {
let el = source
let top = -el.scrollTop
let left = 0
while (el && el !== target) {
top += el.offsetTop - el.scrollTop
left += el.offsetLeft
el = el.offsetParent
}
return {top, left}
}

function getRelativeRect(element, parent): Rect {
return {
...getOffsetsTo(element, parent),
width: element.offsetWidth,
height: element.offsetHeight
}
}

function regionsWithComputedRects<T>(
regions: ReportedRegion<T>[],
parent
): RegionsWithComputedRects<T>[] {
return regions.map(region => ({...region, rect: getRelativeRect(region.element, parent)}))
}

export function StickyOverlay(props: Props) {
const {children, trackerRef, margins = DEFAULT_MARGINS} = props
const regions = regionsWithComputedRects(props.regions, trackerRef.current)
const renderCallback = React.useCallback(
(regionsWithIntersectionDetails: RegionWithIntersectionDetails[], containerWidth) => {
const grouped = group(
Expand Down

0 comments on commit 15d4c1b

Please sign in to comment.