@cntrl-site/sdk-nextjs 1.0.20 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/jest.config.js +2 -2
- package/lib/components/Article.js +9 -8
- package/lib/components/Item.js +39 -21
- package/lib/components/items/CodeEmbedItem.js +15 -12
- package/lib/components/items/CustomItem.js +7 -9
- package/lib/components/items/GroupItem.js +15 -12
- package/lib/components/items/ImageItem.js +28 -21
- package/lib/components/items/RectangleItem.js +28 -15
- package/lib/components/items/RichTextItem.js +33 -12
- package/lib/components/items/VideoItem.js +26 -19
- package/lib/components/items/VimeoEmbed.js +22 -20
- package/lib/components/items/YoutubeEmbed.js +20 -18
- package/lib/components/useItemPosition.js +10 -5
- package/lib/interactions/CSSPropertyNameMap.js +38 -0
- package/lib/interactions/InteractionsRegistry.js +220 -0
- package/lib/interactions/ItemInteractionCtrl.js +61 -0
- package/lib/interactions/getTransition.js +21 -0
- package/lib/interactions/types.js +2 -0
- package/lib/interactions/useItemInteractionCtrl.js +17 -0
- package/lib/provider/InteractionsContext.js +23 -0
- package/lib/utils/getStyleFromItemStateAndParams.js +9 -0
- package/package.json +7 -4
- package/src/components/Article.tsx +30 -27
- package/src/components/ArticleWrapper.tsx +1 -2
- package/src/components/Item.tsx +75 -29
- package/src/components/items/CodeEmbedItem.tsx +15 -11
- package/src/components/items/CustomItem.tsx +9 -11
- package/src/components/items/GroupItem.tsx +17 -13
- package/src/components/items/ImageItem.tsx +35 -20
- package/src/components/items/RectangleItem.tsx +35 -14
- package/src/components/items/RichTextItem.tsx +41 -14
- package/src/components/items/VideoItem.tsx +34 -20
- package/src/components/items/VimeoEmbed.tsx +27 -19
- package/src/components/items/YoutubeEmbed.tsx +20 -18
- package/src/components/useItemPosition.ts +12 -5
- package/src/interactions/CSSPropertyNameMap.ts +38 -0
- package/src/interactions/InteractionsRegistry.ts +244 -0
- package/src/interactions/ItemInteractionCtrl.ts +62 -0
- package/src/interactions/getTransition.ts +27 -0
- package/src/interactions/types.ts +32 -0
- package/src/interactions/useItemInteractionCtrl.ts +18 -0
- package/src/provider/InteractionsContext.old.tsx +65 -0
- package/src/provider/InteractionsContext.test.tsx +97 -0
- package/src/provider/InteractionsContext.tsx +28 -0
- package/src/utils/getStyleFromItemStateAndParams.ts +8 -0
- package/lib/utils/HoverStyles/HoverStyles.js +0 -77
- package/src/utils/HoverStyles/HoverStyles.ts +0 -85
|
@@ -3,7 +3,11 @@ import { useKeyframeValue } from '../common/useKeyframeValue';
|
|
|
3
3
|
import { getItemTopStyle } from '../utils/getItemTopStyle';
|
|
4
4
|
import { useLayoutContext } from './useLayoutContext';
|
|
5
5
|
|
|
6
|
-
export const useItemPosition = (
|
|
6
|
+
export const useItemPosition = (
|
|
7
|
+
item: ItemAny,
|
|
8
|
+
sectionId: string,
|
|
9
|
+
stateValues: { top?: number; left?: number; }
|
|
10
|
+
) => {
|
|
7
11
|
const layoutId = useLayoutContext();
|
|
8
12
|
const position = useKeyframeValue<{ top: number; left: number } | undefined>(
|
|
9
13
|
item,
|
|
@@ -18,12 +22,15 @@ export const useItemPosition = (item: ItemAny, sectionId: string) => {
|
|
|
18
22
|
);
|
|
19
23
|
const anchorSide = layoutId ? item.area[layoutId].anchorSide : AnchorSide.Top;
|
|
20
24
|
const positionType = layoutId ? item.area[layoutId].positionType : PositionType.ScreenBased;
|
|
21
|
-
//
|
|
25
|
+
// to prevent fixed item (with anchor point bottom) to jump when scroll in safari on mobile
|
|
22
26
|
const isScreenBasedBottom = positionType === PositionType.ScreenBased && anchorSide === AnchorSide.Bottom;
|
|
27
|
+
if (!position) return undefined;
|
|
28
|
+
const top = stateValues?.top ?? position.top;
|
|
29
|
+
const left = stateValues?.left ?? position.left;
|
|
23
30
|
return position ? {
|
|
24
|
-
bottom: isScreenBasedBottom ? `${-
|
|
25
|
-
top: isScreenBasedBottom ? 'unset' : getItemTopStyle(
|
|
26
|
-
left: `${
|
|
31
|
+
bottom: isScreenBasedBottom ? `${-top * 100}vw` : 'unset',
|
|
32
|
+
top: isScreenBasedBottom ? 'unset' : getItemTopStyle(top, anchorSide),
|
|
33
|
+
left: `${left * 100}vw`
|
|
27
34
|
} : undefined;
|
|
28
35
|
};
|
|
29
36
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ArticleItemType, ItemState } from '@cntrl-site/sdk';
|
|
2
|
+
|
|
3
|
+
export const CSSPropertyNameMap: Record<keyof ItemState<ArticleItemType>, string> = {
|
|
4
|
+
'width': 'width',
|
|
5
|
+
'height': 'height',
|
|
6
|
+
'top': 'top',
|
|
7
|
+
'left': 'left',
|
|
8
|
+
'scale': 'transform',
|
|
9
|
+
'angle': 'transform',
|
|
10
|
+
'opacity': 'opacity',
|
|
11
|
+
'radius': 'border-radius',
|
|
12
|
+
'strokeWidth': 'border-width',
|
|
13
|
+
'strokeColor': 'border-color',
|
|
14
|
+
'fillColor': 'background-color',
|
|
15
|
+
'blur': 'filter',
|
|
16
|
+
'backdropBlur': 'backdrop-filter',
|
|
17
|
+
'letterSpacing': 'letter-spacing',
|
|
18
|
+
'wordSpacing': 'word-spacing',
|
|
19
|
+
'color': 'color'
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const PropertyNameCSSMap: Record<string, string[]> = {
|
|
23
|
+
'transform': ['angle', 'scale'],
|
|
24
|
+
'border-radius': ['radius'],
|
|
25
|
+
'border-width': ['strokeWidth'],
|
|
26
|
+
'border-color': ['strokeColor'],
|
|
27
|
+
'background-color': ['fillColor'],
|
|
28
|
+
'filter': ['blur'],
|
|
29
|
+
'backdrop-filter': ['backdrop-blur'],
|
|
30
|
+
'letter-spacing': ['letterSpacing'],
|
|
31
|
+
'word-spacing': ['wordSpacing'],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
export function getStyleKeysFromCSSProperty(cssProp: string): string[] {
|
|
36
|
+
const key = PropertyNameCSSMap[cssProp] ?? [cssProp];
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { InteractionsRegistryPort, ItemInteractionCtrl } from './types';
|
|
2
|
+
import {
|
|
3
|
+
Article,
|
|
4
|
+
ArticleItemType,
|
|
5
|
+
Interaction,
|
|
6
|
+
InteractionTrigger,
|
|
7
|
+
ItemAny,
|
|
8
|
+
} from '@cntrl-site/sdk';
|
|
9
|
+
import { isItemType } from '../components/Item';
|
|
10
|
+
|
|
11
|
+
export class InteractionsRegistry implements InteractionsRegistryPort {
|
|
12
|
+
private ctrls: Map<ItemId, ItemInteractionCtrl> = new Map();
|
|
13
|
+
private items: ItemAny[];
|
|
14
|
+
private interactions: Interaction[];
|
|
15
|
+
private stateItemsIdsMap: StateItemsIdsMap;
|
|
16
|
+
private interactionStateMap: InteractionStateMap;
|
|
17
|
+
private itemsStages: ItemStages;
|
|
18
|
+
private activeStateIdInteractionIdMap: Record<StateId, InteractionId>;
|
|
19
|
+
|
|
20
|
+
constructor(article: Article, private layoutId: string) {
|
|
21
|
+
const { interactions } = article;
|
|
22
|
+
this.items = this.unpackItems(article);
|
|
23
|
+
const activeStatesIds = interactions.reduce<StateId[]>((map, inter) => {
|
|
24
|
+
const activeStateId = inter.states.find((state) => state.id !== inter.startStateId)?.id!;
|
|
25
|
+
map.push(activeStateId);
|
|
26
|
+
return map;
|
|
27
|
+
}, []);
|
|
28
|
+
const interactionStateMap = interactions.reduce<InteractionStateMap>((map, { id, startStateId }) => {
|
|
29
|
+
map[id] = startStateId;
|
|
30
|
+
return map;
|
|
31
|
+
}, {});
|
|
32
|
+
this.activeStateIdInteractionIdMap = interactions.reduce<Record<StateId, InteractionId>>((map, interaction) => {
|
|
33
|
+
const activeState = interaction.states.find((state) => state.id !== interaction.startStateId);
|
|
34
|
+
if (activeState) {
|
|
35
|
+
map[activeState.id] = interaction.id;
|
|
36
|
+
}
|
|
37
|
+
return map;
|
|
38
|
+
}, {});
|
|
39
|
+
const itemStages = this.getDefaultItemStages();
|
|
40
|
+
const stateItemsIdsMap = activeStatesIds.reduce<StateItemsIdsMap>((map, stateId) => {
|
|
41
|
+
map[stateId] = this.items
|
|
42
|
+
.filter((item) => {
|
|
43
|
+
const layoutStates = item.state[layoutId] ?? {};
|
|
44
|
+
const state = layoutStates?.[stateId] ?? {};
|
|
45
|
+
const hasKeys = Object.keys(state).length !== 0;
|
|
46
|
+
return hasKeys;
|
|
47
|
+
})
|
|
48
|
+
.map((item) => item.id);
|
|
49
|
+
return map;
|
|
50
|
+
}, {});
|
|
51
|
+
this.interactions = interactions;
|
|
52
|
+
this.itemsStages = itemStages;
|
|
53
|
+
this.stateItemsIdsMap = stateItemsIdsMap;
|
|
54
|
+
this.interactionStateMap = interactionStateMap;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
register(itemId: ItemId, ctrl: ItemInteractionCtrl) {
|
|
58
|
+
this.ctrls.set(itemId, ctrl);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getStatePropsForItem(itemId: string) {
|
|
62
|
+
const { items, layoutId } = this;
|
|
63
|
+
const item = items.find((item) => item.id === itemId)!;
|
|
64
|
+
const itemStages = this.itemsStages.filter((stage) => stage.itemId === itemId);
|
|
65
|
+
itemStages.sort((a, b) => a.updated - b.updated);
|
|
66
|
+
const itemStyles: StateProps = {};
|
|
67
|
+
for (const stage of itemStages) {
|
|
68
|
+
if (stage.type === 'active') {
|
|
69
|
+
if (stage.isStartState) continue;
|
|
70
|
+
const params = item.state[layoutId]?.[stage.stateId!] ?? {};
|
|
71
|
+
for (const [key, stateDetails] of Object.entries(params)) {
|
|
72
|
+
itemStyles[key] = {
|
|
73
|
+
value: stateDetails.value
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (stage.type === 'transitioning') {
|
|
78
|
+
const activeStateId = stage.direction === 'in' ? stage.to : stage.from;
|
|
79
|
+
const params = item.state[layoutId]?.[activeStateId] ?? {};
|
|
80
|
+
for (const [key, stateDetails] of Object.entries(params)) {
|
|
81
|
+
itemStyles[key] = {
|
|
82
|
+
value: stage.direction === 'in' ? stateDetails.value : itemStyles[key]?.value,
|
|
83
|
+
transition: {
|
|
84
|
+
timing: stateDetails[stage.direction].timing,
|
|
85
|
+
duration: stateDetails[stage.direction].duration,
|
|
86
|
+
delay: stateDetails[stage.direction].delay
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return itemStyles;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
notifyTrigger(itemId: string, triggerType: TriggerType): void {
|
|
96
|
+
const timestamp = Date.now();
|
|
97
|
+
for (const interaction of this.interactions) {
|
|
98
|
+
const currentStateId = this.getCurrentStateByInteractionId(interaction.id);
|
|
99
|
+
const matchingTrigger = interaction.triggers.find((trigger) =>
|
|
100
|
+
trigger.itemId === itemId
|
|
101
|
+
&& trigger.from === currentStateId
|
|
102
|
+
&& trigger.type === triggerType
|
|
103
|
+
);
|
|
104
|
+
if (!matchingTrigger) continue;
|
|
105
|
+
const activeStateId = this.getActiveInteractionState(interaction.id);
|
|
106
|
+
const isNewStateActive = matchingTrigger.to === activeStateId;
|
|
107
|
+
this.setCurrentStateForInteraction(interaction.id, matchingTrigger.to);
|
|
108
|
+
const transitioningItems = this.stateItemsIdsMap[activeStateId] ?? [];
|
|
109
|
+
this.itemsStages = this.itemsStages.map((stage) => {
|
|
110
|
+
if (stage.interactionId !== interaction.id) return stage;
|
|
111
|
+
return {
|
|
112
|
+
itemId: stage.itemId,
|
|
113
|
+
interactionId: stage.interactionId,
|
|
114
|
+
type: 'transitioning',
|
|
115
|
+
from: stage.type === 'transitioning' ? stage.to : stage.stateId!,
|
|
116
|
+
to: matchingTrigger.to,
|
|
117
|
+
direction: isNewStateActive ? 'in' : 'out',
|
|
118
|
+
updated: timestamp
|
|
119
|
+
};
|
|
120
|
+
});
|
|
121
|
+
this.notifyItemCtrlsChange(transitioningItems);
|
|
122
|
+
this.notifyTransitionStartForItems(transitioningItems, activeStateId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
notifyTransitionStartForItems(itemsIds: string[], activeStateId: string) {
|
|
127
|
+
for (const itemId of itemsIds) {
|
|
128
|
+
const ctrl = this.ctrls.get(itemId);
|
|
129
|
+
const item = this.items.find((item) => item.id === itemId)!;
|
|
130
|
+
const keys = Object.keys(item.state[this.layoutId]?.[activeStateId] ?? {});
|
|
131
|
+
ctrl?.handleTransitionStart?.(keys);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
notifyTransitionEnd(itemId: string): void {
|
|
136
|
+
const timestamp = Date.now();
|
|
137
|
+
this.itemsStages = this.itemsStages.map((stage) => {
|
|
138
|
+
if (stage.itemId !== itemId || stage.type !== 'transitioning') return stage;
|
|
139
|
+
return {
|
|
140
|
+
itemId: itemId,
|
|
141
|
+
interactionId: stage.interactionId,
|
|
142
|
+
type: 'active',
|
|
143
|
+
stateId: stage.to,
|
|
144
|
+
isStartState: stage.direction === 'out',
|
|
145
|
+
updated: timestamp
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
this.ctrls.get(itemId)?.receiveChange();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
private getCurrentStateByInteractionId(id: InteractionId): string {
|
|
153
|
+
let state;
|
|
154
|
+
for (const interactionId of Object.keys(this.interactionStateMap)) {
|
|
155
|
+
if (id !== interactionId) continue;
|
|
156
|
+
state = this.interactionStateMap[interactionId];
|
|
157
|
+
}
|
|
158
|
+
if (!state) throw new Error(`Failed to find current state for interaction w/ id="${id}"`);
|
|
159
|
+
return state;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
private setCurrentStateForInteraction(interactionId: InteractionId, stateId: StateId) {
|
|
163
|
+
this.interactionStateMap = {
|
|
164
|
+
...this.interactionStateMap,
|
|
165
|
+
[interactionId]: stateId
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private getActiveInteractionState(interactionId: InteractionId): string {
|
|
170
|
+
const { interactions } = this;
|
|
171
|
+
const interaction = interactions.find((interaction) => interaction.id === interactionId)!;
|
|
172
|
+
return interaction.states.find(state => state.id !== interaction.startStateId)?.id!;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private notifyItemCtrlsChange(itemsIds: string[]) {
|
|
176
|
+
for (const itemId of itemsIds) {
|
|
177
|
+
this.ctrls.get(itemId)?.receiveChange();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private unpackItems(article: Article): ItemAny[] {
|
|
182
|
+
const itemsArr = [];
|
|
183
|
+
for (const section of article.sections) {
|
|
184
|
+
for (const item of section.items) {
|
|
185
|
+
const { items, ...itemWithoutChildren } = item;
|
|
186
|
+
itemsArr.push(itemWithoutChildren);
|
|
187
|
+
if (!isItemType(item, ArticleItemType.Group)) continue;
|
|
188
|
+
const groupChildren = item?.items ?? [];
|
|
189
|
+
for (const child of groupChildren) {
|
|
190
|
+
itemsArr.push(child);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return itemsArr;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private getDefaultItemStages(): ItemStages {
|
|
198
|
+
const timestamp = Date.now();
|
|
199
|
+
const { items, layoutId } = this;
|
|
200
|
+
const stages: ItemStages = [];
|
|
201
|
+
for (const item of items) {
|
|
202
|
+
const itemStatesMap = item.state[layoutId];
|
|
203
|
+
if (!itemStatesMap) continue;
|
|
204
|
+
for (const stateId of Object.keys(itemStatesMap)) {
|
|
205
|
+
const interactionId = this.activeStateIdInteractionIdMap[stateId];
|
|
206
|
+
if (!interactionId) continue;
|
|
207
|
+
stages.push({
|
|
208
|
+
itemId: item.id,
|
|
209
|
+
interactionId,
|
|
210
|
+
type: 'active',
|
|
211
|
+
isStartState: true,
|
|
212
|
+
updated: timestamp
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return stages;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
type ItemStages = (TransitioningStage | ActiveStage)[];
|
|
221
|
+
type TransitioningStage = {
|
|
222
|
+
itemId: string;
|
|
223
|
+
interactionId: string;
|
|
224
|
+
type: 'transitioning';
|
|
225
|
+
from: StateId;
|
|
226
|
+
to: StateId;
|
|
227
|
+
direction: 'in' | 'out';
|
|
228
|
+
updated: number;
|
|
229
|
+
};
|
|
230
|
+
type ActiveStage = { type: 'active'; itemId: string; interactionId: string; stateId?: string; isStartState: boolean; updated: number; };
|
|
231
|
+
type InteractionStateMap = Record<InteractionId, StateId>;
|
|
232
|
+
type StateItemsIdsMap = Record<StateId, ItemId[]>;
|
|
233
|
+
type TriggerType = InteractionTrigger['type'];
|
|
234
|
+
type InteractionId = string;
|
|
235
|
+
type StateId = string;
|
|
236
|
+
type ItemId = string;
|
|
237
|
+
type StateProps = Record<string, {
|
|
238
|
+
value?: string | number;
|
|
239
|
+
transition?: {
|
|
240
|
+
timing: string;
|
|
241
|
+
duration: number;
|
|
242
|
+
delay: number;
|
|
243
|
+
};
|
|
244
|
+
}>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { InteractionsRegistryPort, ItemInteractionCtrl } from './types';
|
|
2
|
+
import { getTransition } from './getTransition';
|
|
3
|
+
import { getStyleKeysFromCSSProperty } from './CSSPropertyNameMap';
|
|
4
|
+
|
|
5
|
+
export class ItemInteractionController implements ItemInteractionCtrl {
|
|
6
|
+
private transitionsInProgress: Set<string> = new Set();
|
|
7
|
+
constructor(
|
|
8
|
+
private itemId: string,
|
|
9
|
+
private registry: InteractionsRegistryPort,
|
|
10
|
+
private onChange: () => void
|
|
11
|
+
) {
|
|
12
|
+
this.registry.register(itemId, this);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
getState(keys: string[]) {
|
|
16
|
+
const stateProps = this.registry.getStatePropsForItem(this.itemId);
|
|
17
|
+
const styles = keys.reduce<Record<string, string | number>>((map, styleKey) => {
|
|
18
|
+
const prop = stateProps[styleKey];
|
|
19
|
+
if (prop?.value === undefined) return map;
|
|
20
|
+
map[styleKey] = prop.value;
|
|
21
|
+
return map;
|
|
22
|
+
}, {});
|
|
23
|
+
const transition = getTransition(stateProps, keys);
|
|
24
|
+
return {
|
|
25
|
+
styles,
|
|
26
|
+
transition
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
sendTrigger(type: 'click' | 'hover-in' | 'hover-out') {
|
|
31
|
+
this.registry.notifyTrigger(this.itemId, type);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
handleTransitionStart = (types: string[]) => {
|
|
35
|
+
this.transitionsInProgress.clear();
|
|
36
|
+
for (const type of types) {
|
|
37
|
+
this.transitionsInProgress.add(type);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
handleTransitionEnd = (cssPropKey: string) => {
|
|
42
|
+
if (cssPropKey.startsWith('border-') && cssPropKey.endsWith('-radius')) {
|
|
43
|
+
cssPropKey = 'border-radius';
|
|
44
|
+
}
|
|
45
|
+
if (cssPropKey.startsWith('border-') && cssPropKey.endsWith('-width')) {
|
|
46
|
+
cssPropKey = 'border-width';
|
|
47
|
+
}
|
|
48
|
+
const styleKeys = getStyleKeysFromCSSProperty(cssPropKey);
|
|
49
|
+
for (const key of styleKeys) {
|
|
50
|
+
const found = this.transitionsInProgress.has(key);
|
|
51
|
+
if (!found) continue;
|
|
52
|
+
this.transitionsInProgress.delete(key);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
if (this.transitionsInProgress.size !== 0) return;
|
|
56
|
+
this.registry.notifyTransitionEnd(this.itemId);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
receiveChange() {
|
|
60
|
+
this.onChange();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CSSPropertyNameMap } from './CSSPropertyNameMap';
|
|
2
|
+
|
|
3
|
+
export function getTransition(
|
|
4
|
+
state: Record<string, StateInfo>,
|
|
5
|
+
keys: string[]
|
|
6
|
+
): string {
|
|
7
|
+
if (Object.keys(state).length === 0) return 'none';
|
|
8
|
+
const transitions = [];
|
|
9
|
+
for (const [key, params] of Object.entries(state)) {
|
|
10
|
+
if (!keys.includes(key) || !params.transition) continue;
|
|
11
|
+
const { transition: { duration, delay, timing } } = params;
|
|
12
|
+
const cssKey = CSSPropertyNameMap[key]!;
|
|
13
|
+
const nonZeroDuration = Math.max(duration, 0.01);
|
|
14
|
+
transitions.push(`${cssKey} ${nonZeroDuration}ms ${timing} ${delay}ms`);
|
|
15
|
+
}
|
|
16
|
+
return transitions.length > 0
|
|
17
|
+
? transitions.join(', ')
|
|
18
|
+
: 'none';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type StateInfo = {
|
|
22
|
+
transition?: {
|
|
23
|
+
duration: number;
|
|
24
|
+
delay: number;
|
|
25
|
+
timing: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { ArticleItemType, ItemState } from '@cntrl-site/sdk';
|
|
2
|
+
|
|
3
|
+
export interface ItemInteractionCtrl {
|
|
4
|
+
getState(keys: string[]): StateCSSInfo;
|
|
5
|
+
sendTrigger(type: 'click' | 'hover-in' | 'hover-out'): void;
|
|
6
|
+
handleTransitionEnd?: (styleKey: string) => void;
|
|
7
|
+
handleTransitionStart?: (styleKeys: string[]) => void;
|
|
8
|
+
receiveChange: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InteractionsRegistryPort {
|
|
12
|
+
register(itemId: string, ctrl: ItemInteractionCtrl): void;
|
|
13
|
+
getStatePropsForItem(itemId: string): StateProps;
|
|
14
|
+
notifyTrigger(itemId: string, type: TriggerType): void;
|
|
15
|
+
notifyTransitionEnd(itemId: string): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type StateCSSInfo = {
|
|
19
|
+
styles: Partial<Record<string, string | number>>;
|
|
20
|
+
transition?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type StateProps = Record<keyof ItemState<ArticleItemType>, {
|
|
24
|
+
value?: string | number;
|
|
25
|
+
transition?: {
|
|
26
|
+
timing: string;
|
|
27
|
+
duration: number;
|
|
28
|
+
delay: number;
|
|
29
|
+
};
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
type TriggerType = 'click' | 'hover-in' | 'hover-out';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useMemo, useState } from 'react';
|
|
2
|
+
import { ItemInteractionController } from './ItemInteractionCtrl';
|
|
3
|
+
import { ItemInteractionCtrl } from './types';
|
|
4
|
+
import { useInteractionsRegistry } from '../provider/InteractionsContext';
|
|
5
|
+
|
|
6
|
+
export function useItemInteractionCtrl(itemId: string): ItemInteractionCtrl | undefined {
|
|
7
|
+
const [_, triggerRender] = useState(0);
|
|
8
|
+
const registry = useInteractionsRegistry();
|
|
9
|
+
const ctrl = useMemo(() => {
|
|
10
|
+
if (!registry) return;
|
|
11
|
+
return new ItemInteractionController(
|
|
12
|
+
itemId,
|
|
13
|
+
registry,
|
|
14
|
+
() => triggerRender(prev => prev + 1)
|
|
15
|
+
);
|
|
16
|
+
}, [itemId, registry]);
|
|
17
|
+
return ctrl;
|
|
18
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createContext, FC, PropsWithChildren, useState } from 'react';
|
|
2
|
+
import { Interaction, InteractionTrigger } from '@cntrl-site/sdk';
|
|
3
|
+
|
|
4
|
+
const defaultState = {
|
|
5
|
+
interactionsStatesMap: {},
|
|
6
|
+
interactions: [],
|
|
7
|
+
transitionTo: () => {},
|
|
8
|
+
getItemTrigger: () => null
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const InteractionsContextOld = createContext<{
|
|
12
|
+
interactionsStatesMap: StatesMap,
|
|
13
|
+
interactions: Interaction[],
|
|
14
|
+
transitionTo: (interactionId: string, stateId: string) => void,
|
|
15
|
+
getItemTrigger: (itemId: string, triggerType: TriggerType) => Trigger | null
|
|
16
|
+
}>(defaultState);
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
interactions: Interaction[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const InteractionsProvider: FC<PropsWithChildren<Props>> = ({ interactions, children }) => {
|
|
23
|
+
const defaultStatesMap = interactions.reduce<Record<string, string>>((map, { id, startStateId }) => {
|
|
24
|
+
map[id] = startStateId;
|
|
25
|
+
return map;
|
|
26
|
+
}, {});
|
|
27
|
+
const [interactionsStatesMap, setInteractionsStatesMap] = useState(defaultStatesMap);
|
|
28
|
+
const transitionTo = (interactionId: string, stateId: string) => {
|
|
29
|
+
setInteractionsStatesMap((map) => ({ ...map, [interactionId]: stateId }));
|
|
30
|
+
};
|
|
31
|
+
const getItemTrigger = (itemId: string, triggerType: TriggerType): Trigger | null => {
|
|
32
|
+
for (const interaction of interactions) {
|
|
33
|
+
const activeStateId = interactionsStatesMap[interaction.id];
|
|
34
|
+
const matchingTrigger = interaction.triggers.find((trigger) =>
|
|
35
|
+
trigger.itemId === itemId &&
|
|
36
|
+
trigger.from === activeStateId &&
|
|
37
|
+
trigger.type === triggerType
|
|
38
|
+
);
|
|
39
|
+
if (matchingTrigger) {
|
|
40
|
+
return {
|
|
41
|
+
id: interaction.id,
|
|
42
|
+
from: matchingTrigger.from,
|
|
43
|
+
to: matchingTrigger.to,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
};
|
|
49
|
+
return (
|
|
50
|
+
<InteractionsContextOld.Provider value={{
|
|
51
|
+
transitionTo,
|
|
52
|
+
interactionsStatesMap,
|
|
53
|
+
interactions,
|
|
54
|
+
getItemTrigger
|
|
55
|
+
}}>
|
|
56
|
+
{children}
|
|
57
|
+
</InteractionsContextOld.Provider>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type StatesMap = Record<InteractionId, StateId>;
|
|
62
|
+
type Trigger = { id: InteractionId, from: StateId, to: StateId };
|
|
63
|
+
type TriggerType = InteractionTrigger['type'];
|
|
64
|
+
type InteractionId = string;
|
|
65
|
+
type StateId = string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, waitFor } from '@testing-library/react';
|
|
3
|
+
import { InteractionsProvider, InteractionsContextOld } from './InteractionsContext.old';
|
|
4
|
+
import { Interaction } from '@cntrl-site/sdk';
|
|
5
|
+
|
|
6
|
+
describe('InteractionsProvider', () => {
|
|
7
|
+
const interactions: Interaction[] = [
|
|
8
|
+
{
|
|
9
|
+
id: 'interaction1',
|
|
10
|
+
startStateId: 'state1',
|
|
11
|
+
triggers: [
|
|
12
|
+
{ itemId: 'item1', type: 'click', from: 'state1', to: 'state2' }
|
|
13
|
+
],
|
|
14
|
+
states: [{ id: 'state1' }, { id: 'state2' }]
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
id: 'interaction2',
|
|
18
|
+
startStateId: 'state3',
|
|
19
|
+
triggers: [
|
|
20
|
+
{ itemId: 'item2', type: 'hover-in', from: 'state3', to: 'state4' }
|
|
21
|
+
],
|
|
22
|
+
states: [{ id: 'state3' }, { id: 'state4' }]
|
|
23
|
+
}
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
it('should generate correct default interactionsStatesMap', () => {
|
|
27
|
+
let contextValue;
|
|
28
|
+
|
|
29
|
+
render(
|
|
30
|
+
<InteractionsProvider interactions={interactions}>
|
|
31
|
+
<InteractionsContextOld.Consumer>
|
|
32
|
+
{value => {
|
|
33
|
+
contextValue = value;
|
|
34
|
+
return null;
|
|
35
|
+
}}
|
|
36
|
+
</InteractionsContextOld.Consumer>
|
|
37
|
+
</InteractionsProvider>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(contextValue!).toBeDefined();
|
|
41
|
+
expect(contextValue!.interactionsStatesMap).toEqual({
|
|
42
|
+
interaction1: 'state1',
|
|
43
|
+
interaction2: 'state3'
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should correctly update interactionsStatesMap when transitionTo is called', async () => {
|
|
48
|
+
let contextValue;
|
|
49
|
+
|
|
50
|
+
render(
|
|
51
|
+
<InteractionsProvider interactions={interactions}>
|
|
52
|
+
<InteractionsContextOld.Consumer>
|
|
53
|
+
{value => {
|
|
54
|
+
contextValue = value;
|
|
55
|
+
return null;
|
|
56
|
+
}}
|
|
57
|
+
</InteractionsContextOld.Consumer>
|
|
58
|
+
</InteractionsProvider>
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(contextValue!).toBeDefined();
|
|
62
|
+
contextValue!.transitionTo('interaction1', 'state2');
|
|
63
|
+
|
|
64
|
+
await waitFor(() => {
|
|
65
|
+
expect(contextValue!.interactionsStatesMap['interaction1']).toBe('state2');
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should return the correct trigger using getItemTrigger', () => {
|
|
70
|
+
let contextValue;
|
|
71
|
+
|
|
72
|
+
render(
|
|
73
|
+
<InteractionsProvider interactions={interactions}>
|
|
74
|
+
<InteractionsContextOld.Consumer>
|
|
75
|
+
{value => {
|
|
76
|
+
contextValue = value;
|
|
77
|
+
return null;
|
|
78
|
+
}}
|
|
79
|
+
</InteractionsContextOld.Consumer>
|
|
80
|
+
</InteractionsProvider>
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
expect(contextValue!).toBeDefined();
|
|
84
|
+
|
|
85
|
+
// Check the correct trigger is returned
|
|
86
|
+
const trigger = contextValue!.getItemTrigger('item1', 'click');
|
|
87
|
+
expect(trigger).toEqual({
|
|
88
|
+
id: 'interaction1',
|
|
89
|
+
from: 'state1',
|
|
90
|
+
to: 'state2',
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Check that no trigger is returned when conditions don't match
|
|
94
|
+
const noTrigger = contextValue!.getItemTrigger('item1', 'hover-on');
|
|
95
|
+
expect(noTrigger).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { createContext, FC, PropsWithChildren, useContext, useMemo } from 'react';
|
|
2
|
+
import { InteractionsRegistry } from '../interactions/InteractionsRegistry';
|
|
3
|
+
import { Article } from '@cntrl-site/sdk';
|
|
4
|
+
import { useCurrentLayout } from '../common/useCurrentLayout';
|
|
5
|
+
|
|
6
|
+
export const InteractionsContext = createContext<InteractionsRegistry | undefined>(undefined);
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
article: Article;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const InteractionsProvider: FC<PropsWithChildren<Props>> = ({ article, children }) => {
|
|
13
|
+
const { layoutId } = useCurrentLayout();
|
|
14
|
+
const registry = useMemo(() => {
|
|
15
|
+
if (!layoutId) return;
|
|
16
|
+
return new InteractionsRegistry(article, layoutId);
|
|
17
|
+
}, [layoutId]);
|
|
18
|
+
return (
|
|
19
|
+
<InteractionsContext.Provider value={registry}>
|
|
20
|
+
{children}
|
|
21
|
+
</InteractionsContext.Provider>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export function useInteractionsRegistry(): InteractionsRegistry | undefined {
|
|
26
|
+
const registry = useContext(InteractionsContext);
|
|
27
|
+
return registry;
|
|
28
|
+
}
|