import { InteractionsRegistryPort, ItemInteractionCtrl } from './types'; import { Article, ArticleItemType, Interaction, InteractionItemTrigger, ItemAny, InteractionItemScrollTrigger, InteractionScrollTrigger, } from '@cntrl-site/sdk'; import { isItemType } from '../utils/isItemType'; import { ItemGeometryService } from '../ItemGeometry/ItemGeometryService'; export class InteractionsRegistry implements InteractionsRegistryPort { private ctrls: Map = new Map(); private items: ItemAny[]; private itemGeometryService: ItemGeometryService; private interactions: Interaction[]; private stateItemsIdsMap: StateItemsIdsMap; private interactionStateMap: InteractionStateMap; private itemsStages: ItemStages; private activeStateIdInteractionIdMap: Record; private itemScrollPositionAnimationMap: Map = new Map(); constructor(article: Article, layoutId: string, itemGeometryService: ItemGeometryService) { this.items = this.unpackItems(article); this.itemGeometryService = itemGeometryService; const interactions = article.interactions[layoutId] ?? []; const activeStatesIds = interactions.reduce((map, inter) => { const activeStateId = inter.states.find((state) => state.id !== inter.startStateId)?.id; if (!activeStateId) { throw new Error(`Failed to find active state for interaction w/ id="${inter.id}"`); } map.push(activeStateId); return map; }, []); const interactionStateMap = interactions.reduce((map, { id, startStateId }) => { map[id] = startStateId; return map; }, {}); this.activeStateIdInteractionIdMap = interactions.reduce>((map, interaction) => { const activeState = interaction.states.find((state) => state.id !== interaction.startStateId); if (activeState) { map[activeState.id] = interaction.id; } return map; }, {}); const stateItemsIdsMap = activeStatesIds.reduce((map, stateId) => { map[stateId] = this.items .filter((item) => { const state = item.state[stateId] ?? {}; const hasKeys = Object.keys(state).length !== 0; return hasKeys; }) .map((item) => item.id); return map; }, {}); this.interactions = interactions; this.stateItemsIdsMap = stateItemsIdsMap; this.interactionStateMap = interactionStateMap; const itemStages = this.getDefaultItemStages(); this.itemsStages = itemStages; this.itemScrollPositionAnimationMap = new Map(); } register(itemId: ItemId, ctrl: ItemInteractionCtrl) { this.ctrls.set(itemId, ctrl); } getStatePropsForItem(itemId: string) { const { items } = this; const item = items.find((item) => item.id === itemId)!; const itemStages = this.itemsStages.filter((stage) => stage.itemId === itemId); itemStages.sort((a, b) => a.updated - b.updated); const itemStyles: StateProps = {}; for (const stage of itemStages) { if (stage.type === 'active') { if (stage.isStartState) continue; const params = item.state[stage.stateId!] ?? {}; for (const [key, stateDetails] of Object.entries(params)) { itemStyles[key] = { value: stateDetails.value }; } } if (stage.type === 'transitioning') { const activeStateId = stage.direction === 'in' ? stage.to : stage.from; const params = item.state[activeStateId] ?? {}; for (const [key, stateDetails] of Object.entries(params)) { const transitionDetails = stateDetails[stage.direction]; if (!transitionDetails) continue; itemStyles[key] = { value: stage.direction === 'in' ? stateDetails.value : itemStyles[key]?.value, transition: { timing: transitionDetails.timing, duration: transitionDetails.duration, delay: transitionDetails.delay } }; } } } return itemStyles; } isItemScrollBasedTrigger(itemId: string): boolean { let isElementHasScrollBasedTrigger = false; for (const interaction of this.interactions) { const hasInteractionItemScrollBasedTrigger = interaction.triggers.find((tr) => (tr.type === 'item-scroll-position' && tr.itemId === itemId)) !== undefined; const hasInteractionScrollBasedTrigger = interaction.triggers.find((tr) => tr.type === 'scroll-position') !== undefined; if (hasInteractionScrollBasedTrigger) { for (const trigger of interaction.triggers) { if (trigger.type === 'item' && trigger.itemId === itemId) { isElementHasScrollBasedTrigger = true; return true; } } } if (hasInteractionItemScrollBasedTrigger) { isElementHasScrollBasedTrigger = true; } } return isElementHasScrollBasedTrigger; } getItemAvailableTriggers(itemId: string): Set { const available = new Set(); const activeStates = Object.values(this.interactionStateMap); if (this.isItemScrollBasedTrigger(itemId)) return available; for (const interaction of this.interactions) { const { triggers } = interaction; for (const trigger of triggers) { if (!('triggerEvent' in trigger)) continue; if (trigger.itemId !== itemId) continue; if (activeStates.includes(trigger.from)) { available.add(trigger.triggerEvent); } } } return available; } getTriggerPositionForItemScrollTrigger(trigger: InteractionItemScrollTrigger): number { const itemRect = this.itemGeometryService.getItemBoundary(trigger.itemId); let triggerPosition = 0; switch (trigger.screenPosition) { case 'top': triggerPosition = itemRect.top; break; case 'center': triggerPosition = itemRect.top - window.innerHeight / 2; break; case 'bottom': triggerPosition = itemRect.top - window.innerHeight; break; default: triggerPosition = itemRect.top; break; } switch (trigger.itemPosition) { case 'top': triggerPosition += 0; break; case 'center': triggerPosition += itemRect.height / 2; break; case 'bottom': triggerPosition += itemRect.height; break; default: triggerPosition += 0; break; } triggerPosition += trigger.offset; return triggerPosition; } isItemScrollTriggerOnStartScreen(trigger: InteractionItemScrollTrigger): boolean { const triggerPosition = this.getTriggerPositionForItemScrollTrigger(trigger); return triggerPosition < 0; } notifyLoad() { const timestamp = Date.now(); for (const interaction of this.interactions) { const currentStateId = this.getCurrentStateByInteractionId(interaction.id); const matchingTrigger = interaction.triggers.find(trigger => ('position' in trigger && trigger.position === 0 && trigger.from === currentStateId) || (trigger.type === 'item-scroll-position' && this.items.find((item) => item.id === trigger.itemId) && trigger.from === currentStateId && this.isItemScrollTriggerOnStartScreen(trigger)) ); if (!matchingTrigger) continue; const activeStateId = this.getActiveInteractionState(interaction.id); const isNewStateActive = matchingTrigger.to === activeStateId; this.setCurrentStateForInteraction(interaction.id, matchingTrigger.to); const transitioningItems = this.stateItemsIdsMap[activeStateId] ?? []; const state = interaction.states.find((state) => state.id === matchingTrigger.to); const actions = state?.actions ?? []; for (const action of actions) { const ctrl = this.ctrls.get(action.itemId); if (!ctrl) continue; ctrl.receiveAction(action.type); } this.itemsStages = this.itemsStages.map((stage) => { if (stage.interactionId !== interaction.id) return stage; return { itemId: stage.itemId, interactionId: stage.interactionId, type: 'transitioning', from: stage.type === 'transitioning' ? stage.to : stage.stateId!, to: matchingTrigger.to, direction: isNewStateActive ? 'in' : 'out', updated: timestamp }; }); const itemsToNotify = new Set(transitioningItems); for (const trigger of interaction.triggers) { if (!('itemId' in trigger)) continue; itemsToNotify.add(trigger.itemId); } this.notifyItemCtrlsChange(Array.from(itemsToNotify)); this.notifyTransitionStartForItems(transitioningItems, activeStateId); } } scrollPositionNotifyScroll(trigger: InteractionScrollTrigger, position: number): boolean { if (trigger.position === 0) return false; const triggerPosition = trigger.position * window.innerWidth; return triggerPosition < position; } scrollItemPositionNotifyScroll(trigger: InteractionItemScrollTrigger, position: number, interaction: Interaction): boolean { let triggerPosition; let isScrolledPastTrigger = false; const itemRect = this.itemGeometryService.getItemBoundary(trigger.itemId); if (!itemRect) return false; switch (trigger.screenPosition) { case 'top': triggerPosition = 0; break; case 'center': triggerPosition = window.innerHeight / 2; break; case 'bottom': triggerPosition = window.innerHeight; break; default: triggerPosition = 0; break; } triggerPosition -= trigger.offset; switch (trigger.itemPosition) { case 'top': isScrolledPastTrigger = itemRect.y < triggerPosition; break; case 'center': isScrolledPastTrigger = itemRect.y + itemRect.height / 2 < triggerPosition; break; case 'bottom': isScrolledPastTrigger = itemRect.bottom < triggerPosition; break; default: isScrolledPastTrigger = itemRect.y < triggerPosition; break; } if (isScrolledPastTrigger && !this.itemScrollPositionAnimationMap.get(interaction.id)) { this.itemScrollPositionAnimationMap.set(interaction.id, position + itemRect.y - triggerPosition); } if (this.itemScrollPositionAnimationMap.get(interaction.id)! >= position) isScrolledPastTrigger = false; return isScrolledPastTrigger; } notifyScroll(position: number) { const timestamp = Date.now(); for (const interaction of this.interactions) { const currentStateId = this.getCurrentStateByInteractionId(interaction.id); const activeStateId = interaction.states.find((state) => state.id !== interaction.startStateId)?.id; const matchingTrigger = interaction.triggers.find((trigger) => { if (trigger.type !== 'scroll-position' && trigger.type !== 'item-scroll-position') return false; let isScrolledPastTrigger = false; if (trigger.type === 'scroll-position') { isScrolledPastTrigger = this.scrollPositionNotifyScroll(trigger, position); } if (trigger.type === 'item-scroll-position') { isScrolledPastTrigger = this.scrollItemPositionNotifyScroll(trigger, position, interaction); } if (!isScrolledPastTrigger && !trigger.isReverse) return false; const stateId = isScrolledPastTrigger ? trigger.from : trigger.to; return stateId === currentStateId; }); if (!matchingTrigger || (matchingTrigger.type !== 'scroll-position' && matchingTrigger.type !== 'item-scroll-position') || !activeStateId) continue; let isScrolledPastTrigger = false; if (matchingTrigger.type === 'scroll-position') { const triggerPosition = matchingTrigger.position * window.innerWidth; isScrolledPastTrigger = triggerPosition < position; } if (matchingTrigger.type === 'item-scroll-position') { isScrolledPastTrigger = this.itemScrollPositionAnimationMap.get(interaction.id)! < position; // scroll down } const targetStateId = isScrolledPastTrigger ? matchingTrigger.to : matchingTrigger.from; this.setCurrentStateForInteraction(interaction.id, targetStateId); const transitioningItems = this.stateItemsIdsMap[activeStateId] ?? []; const state = interaction.states.find((state) => state.id === targetStateId); const actions = state?.actions ?? []; for (const action of actions) { const ctrl = this.ctrls.get(action.itemId); if (!ctrl) continue; ctrl.receiveAction(action.type); } const itemsStages = this.itemsStages.map((stage) => { if (stage.interactionId !== interaction.id) return stage; const newStage = { itemId: stage.itemId, interactionId: stage.interactionId, type: 'transitioning' as const, from: stage.type === 'transitioning' ? stage.to : stage.stateId!, to: targetStateId, direction: targetStateId === activeStateId ? 'in' as const : 'out' as const, updated: timestamp }; return newStage; }); this.itemsStages = itemsStages; const itemsToNotify = new Set(transitioningItems); for (const trigger of interaction.triggers) { if (!('itemId' in trigger)) continue; itemsToNotify.add(trigger.itemId); } this.notifyItemCtrlsChange(Array.from(itemsToNotify)); this.notifyTransitionStartForItems(transitioningItems, activeStateId); } } notifyItemTrigger(itemId: string, triggerType: TriggerType): void { const timestamp = Date.now(); if (this.isItemScrollBasedTrigger(itemId)) return; for (const interaction of this.interactions) { const currentStateId = this.getCurrentStateByInteractionId(interaction.id); const hasScrollTriggers = interaction.triggers.find((trigger) => trigger.type === 'item-scroll-position' || trigger.type === 'scroll-position'); const matchingTrigger = interaction.triggers.find((trigger) => 'triggerEvent' in trigger && trigger.itemId === itemId && trigger.from === currentStateId && trigger.triggerEvent === triggerType ); if (!matchingTrigger || hasScrollTriggers) continue; const activeStateId = this.getActiveInteractionState(interaction.id); const isNewStateActive = matchingTrigger.to === activeStateId; this.setCurrentStateForInteraction(interaction.id, matchingTrigger.to); const transitioningItems = this.stateItemsIdsMap[activeStateId] ?? []; const state = interaction.states.find((state) => state.id === matchingTrigger.to); const actions = state?.actions ?? []; for (const action of actions) { const ctrl = this.ctrls.get(action.itemId); if (!ctrl) continue; ctrl.receiveAction(action.type); } this.itemsStages = this.itemsStages.map((stage) => { if (stage.interactionId !== interaction.id) return stage; return { itemId: stage.itemId, interactionId: stage.interactionId, type: 'transitioning', from: stage.type === 'transitioning' ? stage.to : stage.stateId!, to: matchingTrigger.to, direction: isNewStateActive ? 'in' : 'out', updated: timestamp }; }); const itemsToNotify = new Set(transitioningItems); for (const trigger of interaction.triggers) { if (!('itemId' in trigger)) continue; itemsToNotify.add(trigger.itemId); } this.notifyItemCtrlsChange(Array.from(itemsToNotify)); this.notifyTransitionStartForItems(transitioningItems, activeStateId); } } notifyTransitionStartForItems(itemsIds: string[], activeStateId: string) { for (const itemId of itemsIds) { const ctrl = this.ctrls.get(itemId); const item = this.items.find((item) => item.id === itemId)!; const keys = Object.keys(item.state[activeStateId] ?? {}); ctrl?.handleTransitionStart?.(keys); } } notifyTransitionEnd(itemId: string): void { const timestamp = Date.now(); this.itemsStages = this.itemsStages.map((stage) => { if (stage.itemId !== itemId || stage.type !== 'transitioning') return stage; return { itemId, interactionId: stage.interactionId, type: 'active', stateId: stage.to, isStartState: stage.direction === 'out', updated: timestamp }; }); this.ctrls.get(itemId)?.receiveChange(); } notifyPageOverlay(itemId: string): void { this.notifyItemTrigger(itemId, 'hover-out'); } private getCurrentStateByInteractionId(id: InteractionId): string { let state; for (const interactionId of Object.keys(this.interactionStateMap)) { if (id !== interactionId) continue; state = this.interactionStateMap[interactionId]; } if (!state) throw new Error(`Failed to find current state for interaction w/ id="${id}"`); return state; } private setCurrentStateForInteraction(interactionId: InteractionId, stateId: StateId) { this.interactionStateMap = { ...this.interactionStateMap, [interactionId]: stateId }; } private getActiveInteractionState(interactionId: InteractionId): string { const { interactions } = this; const interaction = interactions.find((interaction) => interaction.id === interactionId)!; const activeStateId = interaction.states.find(state => state.id !== interaction.startStateId)?.id; if (!activeStateId) { throw new Error(`Failed to find active state for interaction w/ id="${interactionId}"`); } return activeStateId; } private notifyItemCtrlsChange(itemsIds: string[]) { for (const itemId of itemsIds) { this.ctrls.get(itemId)?.receiveChange(); } } private unpackItems(article: Article): ItemAny[] { const itemsArr = []; for (const section of article.sections) { const items = this.getNestedItems(section.items); itemsArr.push(...items); } return itemsArr; } private getNestedItems(items: ItemAny[]): ItemAny[] { const allItems: ItemAny[] = []; for (const item of items) { if (isItemType(item, ArticleItemType.Group) || isItemType(item, ArticleItemType.Compound)) { const groupChildren = item?.items ?? []; const nestedItems = this.getNestedItems(groupChildren); allItems.push(...nestedItems); } allItems.push(item); } return allItems; } private getDefaultItemStages(): ItemStages { const timestamp = Date.now(); const { items } = this; const stages: ItemStages = []; for (const item of items) { const itemStatesMap = item.state; if (!itemStatesMap) continue; for (const stateId of Object.keys(itemStatesMap)) { const interactionId = this.activeStateIdInteractionIdMap[stateId]; if (!interactionId) continue; stages.push({ itemId: item.id, interactionId, type: 'active', isStartState: true, updated: timestamp, stateId: this.interactions.find((interaction) => interaction.id === interactionId)?.startStateId }); } } return stages; } } type ItemStages = (TransitioningStage | ActiveStage)[]; type TransitioningStage = { itemId: string; interactionId: string; type: 'transitioning'; from: StateId; to: StateId; direction: 'in' | 'out'; updated: number; }; type ActiveStage = { type: 'active'; itemId: string; interactionId: string; stateId?: string; isStartState: boolean; updated: number; }; type InteractionStateMap = Record; type StateItemsIdsMap = Record; type TriggerType = InteractionItemTrigger['triggerEvent']; type InteractionId = string; type StateId = string; type ItemId = string; type StateProps = Record;