@teambit/compositions.ui.composition-compare 0.0.258 → 0.0.260

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.
@@ -1,20 +1,31 @@
1
- import React, { useMemo, useState } from 'react';
1
+ import React, { useMemo, useState, useCallback, useRef, useEffect } from 'react';
2
+ import classNames from 'classnames';
2
3
  import { useComponentCompare } from '@teambit/component.ui.component-compare.context';
3
- import { CompositionContent, CompositionContentProps, EmptyStateSlot } from '@teambit/compositions';
4
- import { CompositionContextProvider } from '@teambit/compositions.ui.hooks.use-composition';
5
4
  import {
6
- useCompareQueryParam,
7
- useUpdatedUrlFromQuery,
8
- } from '@teambit/component.ui.component-compare.hooks.use-component-compare-url';
5
+ CompositionContent,
6
+ LiveControlsDiffPanel,
7
+ useDefaultControlsSchemaResponder,
8
+ type CompositionContentProps,
9
+ type EmptyStateSlot,
10
+ } from '@teambit/compositions';
11
+ import { CompositionContextProvider } from '@teambit/compositions.ui.hooks.use-composition';
12
+ import { useCompareQueryParam } from '@teambit/component.ui.component-compare.hooks.use-component-compare-url';
9
13
  import { CompareSplitLayoutPreset } from '@teambit/component.ui.component-compare.layouts.compare-split-layout-preset';
10
14
  import { RoundLoader } from '@teambit/design.ui.round-loader';
15
+ import { Icon } from '@teambit/evangelist.elements.icon';
16
+ import { useLocation } from '@teambit/base-react.navigation.link';
17
+ import { useQuery } from '@teambit/ui-foundation.ui.react-router.use-query';
11
18
  import queryString from 'query-string';
12
19
  import { CompositionDropdown } from './composition-dropdown';
13
20
  import { CompositionCompareContext } from './composition-compare.context';
14
21
  import { uniqBy } from 'lodash';
22
+ import * as semver from 'semver';
15
23
 
16
24
  import styles from './composition-compare.module.scss';
17
25
 
26
+ const noop = () => {};
27
+ const LOCAL_VERSION = 'workspace';
28
+
18
29
  export type CompositionCompareProps = {
19
30
  emptyState?: EmptyStateSlot;
20
31
  Widgets?: {
@@ -25,180 +36,583 @@ export type CompositionCompareProps = {
25
36
  PreviewView?: React.ComponentType<CompositionContentProps>;
26
37
  };
27
38
 
39
+ type ControlsStatus = 'loading' | 'available' | 'empty';
40
+
41
+ function MissingComposition({ compositionId, version }: { compositionId?: string; version: string }) {
42
+ const message = compositionId
43
+ ? `The selected composition "${compositionId}" does not exist for the ${version} version.`
44
+ : `The selected composition does not exist for the ${version} version.`;
45
+ return (
46
+ <div className={styles.subView}>
47
+ <div className={styles.missingComposition}>
48
+ <div className={styles.missingCompositionTitle}>Composition not available</div>
49
+ <div className={styles.missingCompositionSubtitle}>{message}</div>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function getCompositionTag(hasInBase: boolean, hasInCompare: boolean): string | undefined {
56
+ if (hasInBase && hasInCompare) return undefined;
57
+ if (hasInBase) return 'Base only';
58
+ if (hasInCompare) return 'Compare only';
59
+ return undefined;
60
+ }
61
+
62
+ function useResizePanel(initialHeight: number) {
63
+ const MIN_PANEL_HEIGHT = 80;
64
+ const MIN_COMPARE_HEIGHT = 150;
65
+ const DEFAULT_PANEL_RATIO = 0.34;
66
+ const [panelHeight, setPanelHeight] = useState(initialHeight);
67
+ const [isResizing, setIsResizing] = useState(false);
68
+ const panelRef = useRef<HTMLDivElement>(null);
69
+ const isDragging = useRef(false);
70
+ const panelHeightRef = useRef(initialHeight);
71
+ const removeListenersRef = useRef<(() => void) | null>(null);
72
+ const rafRef = useRef<number | null>(null);
73
+ const initializedRef = useRef(false);
74
+ const userResizedRef = useRef(false);
75
+
76
+ const getMaxPanelHeight = useCallback(() => {
77
+ const containerHeight = panelRef.current?.parentElement?.clientHeight;
78
+ if (!containerHeight) return Math.max(MIN_PANEL_HEIGHT, initialHeight);
79
+ return Math.max(MIN_PANEL_HEIGHT, containerHeight - MIN_COMPARE_HEIGHT);
80
+ }, [MIN_COMPARE_HEIGHT, MIN_PANEL_HEIGHT, initialHeight]);
81
+
82
+ const getDefaultPanelHeight = useCallback(() => {
83
+ const containerHeight = panelRef.current?.parentElement?.clientHeight;
84
+ if (!containerHeight || containerHeight <= 0) return initialHeight;
85
+ return Math.max(initialHeight, Math.round(containerHeight * DEFAULT_PANEL_RATIO));
86
+ }, [initialHeight, DEFAULT_PANEL_RATIO]);
87
+
88
+ const clampPanelHeight = useCallback(
89
+ (height: number) => {
90
+ const maxHeight = getMaxPanelHeight();
91
+ return Math.max(MIN_PANEL_HEIGHT, Math.min(maxHeight, height));
92
+ },
93
+ [MIN_PANEL_HEIGHT, getMaxPanelHeight]
94
+ );
95
+
96
+ const applyPanelHeight = useCallback(
97
+ (height: number, commitToState = false) => {
98
+ const clamped = clampPanelHeight(height);
99
+ panelHeightRef.current = clamped;
100
+ if (panelRef.current) panelRef.current.style.height = `${clamped}px`;
101
+ if (commitToState) setPanelHeight(clamped);
102
+ return clamped;
103
+ },
104
+ [clampPanelHeight]
105
+ );
106
+
107
+ const syncPanelHeight = useCallback(() => {
108
+ if (isDragging.current) return;
109
+ const nextHeight = userResizedRef.current ? panelHeightRef.current : getDefaultPanelHeight();
110
+ applyPanelHeight(nextHeight, true);
111
+ }, [applyPanelHeight, getDefaultPanelHeight]);
112
+
113
+ useEffect(() => {
114
+ if (initializedRef.current) return;
115
+ initializedRef.current = true;
116
+ syncPanelHeight();
117
+ }, [syncPanelHeight]);
118
+
119
+ useEffect(() => {
120
+ const handleWindowResize = () => {
121
+ syncPanelHeight();
122
+ };
123
+
124
+ handleWindowResize();
125
+ window.addEventListener('resize', handleWindowResize);
126
+ return () => window.removeEventListener('resize', handleWindowResize);
127
+ }, [syncPanelHeight]);
128
+
129
+ useEffect(() => {
130
+ if (typeof ResizeObserver === 'undefined') return;
131
+ const parent = panelRef.current?.parentElement;
132
+ if (!parent) return;
133
+
134
+ const observer = new ResizeObserver(() => {
135
+ syncPanelHeight();
136
+ });
137
+ observer.observe(parent);
138
+
139
+ return () => observer.disconnect();
140
+ }, [syncPanelHeight]);
141
+
142
+ const handleResizeStart = useCallback(
143
+ (e: React.MouseEvent) => {
144
+ e.preventDefault();
145
+ e.stopPropagation();
146
+ isDragging.current = true;
147
+ setIsResizing(true);
148
+
149
+ const startY = e.clientY;
150
+ const startHeight = panelHeightRef.current;
151
+
152
+ const handleMouseMove = (moveEvent: MouseEvent) => {
153
+ if (!isDragging.current) return;
154
+ moveEvent.preventDefault();
155
+ const delta = startY - moveEvent.clientY;
156
+ const targetHeight = startHeight + delta;
157
+ if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
158
+ rafRef.current = requestAnimationFrame(() => {
159
+ applyPanelHeight(targetHeight);
160
+ rafRef.current = null;
161
+ });
162
+ };
163
+
164
+ const handleMouseUp = () => {
165
+ if (rafRef.current !== null) {
166
+ cancelAnimationFrame(rafRef.current);
167
+ rafRef.current = null;
168
+ }
169
+ userResizedRef.current = true;
170
+ isDragging.current = false;
171
+ applyPanelHeight(panelHeightRef.current, true);
172
+ setIsResizing(false);
173
+ window.removeEventListener('mousemove', handleMouseMove);
174
+ window.removeEventListener('mouseup', handleMouseUp);
175
+ window.removeEventListener('blur', handleMouseUp);
176
+ document.body.style.cursor = '';
177
+ document.body.style.userSelect = '';
178
+ };
179
+
180
+ removeListenersRef.current?.();
181
+ document.body.style.cursor = 'ns-resize';
182
+ document.body.style.userSelect = 'none';
183
+ window.addEventListener('mousemove', handleMouseMove);
184
+ window.addEventListener('mouseup', handleMouseUp);
185
+ window.addEventListener('blur', handleMouseUp);
186
+ removeListenersRef.current = () => {
187
+ window.removeEventListener('mousemove', handleMouseMove);
188
+ window.removeEventListener('mouseup', handleMouseUp);
189
+ window.removeEventListener('blur', handleMouseUp);
190
+ };
191
+ },
192
+ [applyPanelHeight]
193
+ );
194
+
195
+ useEffect(() => {
196
+ return () => {
197
+ isDragging.current = false;
198
+ if (rafRef.current !== null) {
199
+ cancelAnimationFrame(rafRef.current);
200
+ rafRef.current = null;
201
+ }
202
+ removeListenersRef.current?.();
203
+ removeListenersRef.current = null;
204
+ document.body.style.cursor = '';
205
+ document.body.style.userSelect = '';
206
+ };
207
+ }, []);
208
+
209
+ return { panelRef, panelHeight, isResizing, handleResizeStart };
210
+ }
211
+
212
+ function findComposition(compositions: any[] | undefined, id: string | undefined) {
213
+ if (!id || !compositions) return compositions?.[0];
214
+ return compositions.find((c) => c.identifier === id) || undefined;
215
+ }
216
+
217
+ function useCompositionSelection() {
218
+ const selectedCompositionBaseFile = useCompareQueryParam('compositionBaseFile');
219
+ const selectedCompositionCompareFile = useCompareQueryParam('compositionCompareFile');
220
+ return { selectedCompositionBaseFile, selectedCompositionCompareFile };
221
+ }
222
+
223
+ function buildChannelKey(prefix: string, idStr: string | undefined, compId: string | undefined): string | undefined {
224
+ if (!idStr || !compId) return undefined;
225
+ return `${prefix}:${idStr}:${compId}`;
226
+ }
227
+
228
+ function buildQueryParams(channelKey: string | undefined) {
229
+ const params = { livecontrols: true, ...(channelKey ? { lcchannel: channelKey } : {}) };
230
+ return { params, queryString: queryString.stringify(params) };
231
+ }
232
+
233
+ function buildUpdatedUrlFromQuery(
234
+ query: URLSearchParams,
235
+ pathname: string,
236
+ queryParams: {
237
+ compositionBaseFile?: string;
238
+ compositionCompareFile?: string;
239
+ }
240
+ ) {
241
+ const queryObj = Object.fromEntries(query.entries());
242
+ const updatedObj = { ...queryObj, ...queryParams };
243
+ const updatedQueryString = new URLSearchParams(updatedObj).toString();
244
+ return `${pathname}?${updatedQueryString}`;
245
+ }
246
+
247
+ function resolveVersion(model: any): string | undefined {
248
+ const id = model?.id;
249
+ const versionFromField = id?.version?.toString?.();
250
+ const versionFromToString = typeof id?.toString === 'function' ? id.toString().split('@')[1] : undefined;
251
+ return versionFromField || versionFromToString;
252
+ }
253
+
254
+ function hasStableCompareData(contextLoading: boolean | undefined, base: any, compare: any) {
255
+ return !contextLoading && base !== undefined && compare !== undefined;
256
+ }
257
+
258
+ function resolveRequestedCompositionId({
259
+ selectedCompositionCompareFile,
260
+ selectedCompositionBaseFile,
261
+ compareStateId,
262
+ baseStateId,
263
+ compareCompositions,
264
+ baseCompositions,
265
+ }: {
266
+ selectedCompositionCompareFile?: string;
267
+ selectedCompositionBaseFile?: string;
268
+ compareStateId?: string;
269
+ baseStateId?: string;
270
+ compareCompositions?: any[];
271
+ baseCompositions?: any[];
272
+ }) {
273
+ const explicitId = selectedCompositionCompareFile || selectedCompositionBaseFile;
274
+ const stateId = compareStateId || baseStateId;
275
+ const defaultId = compareCompositions?.[0]?.identifier || baseCompositions?.[0]?.identifier;
276
+ return explicitId || stateId || defaultId;
277
+ }
278
+
279
+ function resolveCompositionId(selectedComposition: any, requestedCompositionId?: string) {
280
+ return selectedComposition?.identifier || requestedCompositionId;
281
+ }
282
+
283
+ function hasMissingComposition(requestedCompositionId: string | undefined, selectedComposition: any) {
284
+ return Boolean(requestedCompositionId && !selectedComposition);
285
+ }
286
+
287
+ function buildControlsResetKey(baseChannelKey: string | undefined, compareChannelKey: string | undefined) {
288
+ return `${baseChannelKey || ''}-${compareChannelKey || ''}`;
289
+ }
290
+
291
+ function formatVersionForLabel(version: string | undefined, opts?: { forceWorkspace?: boolean }) {
292
+ if (opts?.forceWorkspace) return LOCAL_VERSION;
293
+ if (!version) return undefined;
294
+ if (version === LOCAL_VERSION) return LOCAL_VERSION;
295
+ return semver.valid(version) ? version : version.slice(0, 6);
296
+ }
297
+
298
+ function useStableControlLabels(baseModel: any, compareModel: any, compareHasLocalChanges?: boolean) {
299
+ const baseVersion = useMemo(() => formatVersionForLabel(resolveVersion(baseModel)), [baseModel]);
300
+ const compareVersion = useMemo(
301
+ () => formatVersionForLabel(resolveVersion(compareModel), { forceWorkspace: compareHasLocalChanges }),
302
+ [compareModel, compareHasLocalChanges]
303
+ );
304
+ const [stableVersions, setStableVersions] = useState<{
305
+ base?: string;
306
+ compare?: string;
307
+ }>({
308
+ base: baseVersion,
309
+ compare: compareVersion,
310
+ });
311
+
312
+ useEffect(() => {
313
+ setStableVersions((prev) => {
314
+ const nextBase = baseVersion ?? prev.base;
315
+ const nextCompare = compareVersion ?? prev.compare;
316
+ if (nextBase === prev.base && nextCompare === prev.compare) return prev;
317
+ return { base: nextBase, compare: nextCompare };
318
+ });
319
+ }, [baseVersion, compareVersion]);
320
+
321
+ const effectiveBaseVersion = baseVersion ?? stableVersions.base;
322
+ const effectiveCompareVersion = compareVersion ?? stableVersions.compare;
323
+
324
+ return useMemo(
325
+ () => ({
326
+ common: 'Common',
327
+ base: effectiveBaseVersion ? `Base version: ${effectiveBaseVersion}` : 'Base version',
328
+ compare: effectiveCompareVersion ? `Compare version: ${effectiveCompareVersion}` : 'Compare version',
329
+ }),
330
+ [effectiveBaseVersion, effectiveCompareVersion]
331
+ );
332
+ }
333
+
334
+ type CompositionLayoutProps = {
335
+ model: any;
336
+ selected: any;
337
+ queryParams: string;
338
+ compositionParams: Record<string, any>;
339
+ previewViewProps: CompositionContentProps;
340
+ emptyState?: EmptyStateSlot;
341
+ PreviewView: React.ComponentType<CompositionContentProps>;
342
+ contextKey: string;
343
+ isBase?: boolean;
344
+ isCompare?: boolean;
345
+ };
346
+
347
+ function CompositionLayout({
348
+ model,
349
+ selected,
350
+ queryParams,
351
+ compositionParams,
352
+ previewViewProps,
353
+ emptyState,
354
+ PreviewView,
355
+ contextKey,
356
+ isBase,
357
+ isCompare,
358
+ }: CompositionLayoutProps) {
359
+ return (
360
+ <div className={styles.subView}>
361
+ <CompositionCompareContext.Provider
362
+ value={{
363
+ compositionProps: {
364
+ forceHeight: undefined,
365
+ innerBottomPadding: 50,
366
+ ...previewViewProps,
367
+ emptyState,
368
+ component: model,
369
+ queryParams,
370
+ selected,
371
+ },
372
+ isBase,
373
+ isCompare,
374
+ }}
375
+ >
376
+ <CompositionContextProvider queryParams={compositionParams} setQueryParams={noop}>
377
+ <PreviewView
378
+ key={contextKey}
379
+ forceHeight={undefined}
380
+ innerBottomPadding={50}
381
+ {...previewViewProps}
382
+ emptyState={emptyState}
383
+ component={model}
384
+ selected={selected}
385
+ queryParams={queryParams}
386
+ />
387
+ </CompositionContextProvider>
388
+ </CompositionCompareContext.Provider>
389
+ </div>
390
+ );
391
+ }
392
+
28
393
  export function CompositionCompare(props: CompositionCompareProps) {
29
- const { emptyState, PreviewView = CompositionContent, Widgets, previewViewProps = {} } = props;
394
+ const {
395
+ emptyState,
396
+ PreviewView = CompositionContent,
397
+ Widgets,
398
+ previewViewProps = {} as CompositionContentProps,
399
+ } = props;
30
400
 
31
401
  const componentCompareContext = useComponentCompare();
402
+ const query = useQuery();
403
+ const location = useLocation() || { pathname: '/' };
404
+ const { base, compare, baseContext, compareContext, loading: contextLoading } = componentCompareContext || {};
32
405
 
33
- const { base, compare, baseContext, compareContext } = componentCompareContext || {};
406
+ const [isControlsOpen, setControlsOpen] = useState(true);
407
+ const [controlsStatus, setControlsStatus] = useState<ControlsStatus>('loading');
408
+ const [everHadControls, setEverHadControls] = useState(false);
409
+ const { panelRef, panelHeight, isResizing, handleResizeStart } = useResizePanel(240);
34
410
 
411
+ const isStableData = hasStableCompareData(contextLoading, base, compare);
35
412
  const baseCompositions = base?.model.compositions;
36
413
  const compareCompositions = compare?.model.compositions;
37
414
 
38
- const selectedCompositionBaseFile = useCompareQueryParam('compositionBaseFile');
39
- const selectedCompositionCompareFile = useCompareQueryParam('compositionCompareFile');
415
+ const { selectedCompositionBaseFile, selectedCompositionCompareFile } = useCompositionSelection();
40
416
 
41
- const baseState = baseContext?.state?.preview;
42
417
  const compareState = compareContext?.state?.preview;
43
418
  const baseHooks = baseContext?.hooks?.preview;
44
419
  const compareHooks = compareContext?.hooks?.preview;
45
- const selectedBaseFromState = baseState?.id;
46
- const selectedCompareFromState = compareState?.id;
47
-
48
- const selectedBaseCompositionId = selectedBaseFromState || selectedCompositionBaseFile;
49
- const selectedCompositionCompareId = selectedCompareFromState || selectedCompositionCompareFile;
50
-
51
- const selectedBaseComp =
52
- (selectedBaseCompositionId &&
53
- baseCompositions &&
54
- baseCompositions.find((c) => {
55
- return c.identifier === selectedBaseCompositionId;
56
- })) ||
57
- (baseCompositions && baseCompositions[0]);
58
-
59
- const selectedCompareComp =
60
- (selectedCompositionCompareId &&
61
- compareCompositions &&
62
- compareCompositions.find((c) => {
63
- return c.identifier === selectedCompositionCompareId;
64
- })) ||
65
- (compareCompositions && compareCompositions[0]);
66
-
67
- // const baseCompositionDropdownSource =
68
- // baseCompositions?.map((c) => {
69
- // const href = !baseState?.controlled
70
- // ? useUpdatedUrlFromQuery({
71
- // compositionBaseFile: c.identifier,
72
- // compositionCompareFile: selectedCompareComp?.identifier,
73
- // })
74
- // : useUpdatedUrlFromQuery({});
75
-
76
- // const onClick = baseState?.controlled ? baseHooks?.onClick : undefined;
77
-
78
- // return { id: c.identifier, label: c.displayName, href, onClick };
79
- // }) || [];
80
-
81
- // const compareCompositionDropdownSource =
82
- // compareCompositions?.map((c) => {
83
- // const href = !compareState?.controlled
84
- // ? useUpdatedUrlFromQuery({
85
- // compositionBaseFile: selectedBaseComp?.identifier,
86
- // compositionCompareFile: c.identifier,
87
- // })
88
- // : useUpdatedUrlFromQuery({});
89
-
90
- // const onClick = compareState?.controlled ? () => compareHooks?.onClick : undefined;
91
-
92
- // return { id: c.identifier, label: c.displayName, href, onClick };
93
- // }) || [];
94
-
95
- const compositionsDropdownSource = uniqBy((baseCompositions || []).concat(compareCompositions || []), 'identifier')?.map((c) => {
96
- const href = !compareState?.controlled
97
- ? useUpdatedUrlFromQuery({
98
- compositionBaseFile: selectedBaseComp?.identifier,
99
- compositionCompareFile: c.identifier,
100
- })
101
- : useUpdatedUrlFromQuery({});
102
-
103
- const onClick = compareState?.controlled ? (_, __) => {
104
- compareHooks?.onClick?.(_, __);
105
- baseHooks?.onClick?.(_, __);
106
- } : undefined;
107
- return { id: c.identifier, label: c.displayName, href, onClick };
108
- });
109
420
 
110
- const [baseCompositionParams, setBaseCompositionParams] = useState<Record<string, any>>({});
111
- const baseCompQueryParams = useMemo(() => queryString.stringify(baseCompositionParams), [baseCompositionParams]);
421
+ const requestedCompositionId = useMemo(
422
+ () =>
423
+ resolveRequestedCompositionId({
424
+ selectedCompositionCompareFile,
425
+ selectedCompositionBaseFile,
426
+ compareStateId: compareState?.id,
427
+ baseStateId: baseContext?.state?.preview?.id,
428
+ compareCompositions,
429
+ baseCompositions,
430
+ }),
431
+ [
432
+ selectedCompositionCompareFile,
433
+ selectedCompositionBaseFile,
434
+ compareState?.id,
435
+ baseContext?.state?.preview?.id,
436
+ compareCompositions,
437
+ baseCompositions,
438
+ ]
439
+ );
440
+
441
+ const selectedBaseComp = findComposition(baseCompositions, requestedCompositionId);
442
+ const selectedCompareComp = findComposition(compareCompositions, requestedCompositionId);
112
443
 
113
- const [compareCompositionParams, setCompareCompositionParams] = useState<Record<string, any>>({});
114
- const compareCompQueryParams = useMemo(
115
- () => queryString.stringify(compareCompositionParams),
116
- [compareCompositionParams]
444
+ const baseMissing = hasMissingComposition(requestedCompositionId, selectedBaseComp);
445
+ const compareMissing = hasMissingComposition(requestedCompositionId, selectedCompareComp);
446
+
447
+ const baseCompositionIds = useMemo(
448
+ () => new Set((baseCompositions || []).map((c) => c.identifier)),
449
+ [baseCompositions]
450
+ );
451
+ const compareCompositionIds = useMemo(
452
+ () => new Set((compareCompositions || []).map((c) => c.identifier)),
453
+ [compareCompositions]
117
454
  );
118
455
 
119
- const selectedBaseDropdown = selectedBaseComp && {
120
- id: selectedBaseComp.identifier,
121
- label: selectedBaseComp.displayName,
122
- };
456
+ const compositionsDropdownSource = useMemo(() => {
457
+ return uniqBy((baseCompositions || []).concat(compareCompositions || []), 'identifier')?.map((c) => {
458
+ const hasInBase = baseCompositionIds.has(c.identifier);
459
+ const hasInCompare = compareCompositionIds.has(c.identifier);
460
+ const tag = getCompositionTag(hasInBase, hasInCompare);
461
+ const href = !compareState?.controlled
462
+ ? buildUpdatedUrlFromQuery(query, location.pathname, {
463
+ compositionBaseFile: c.identifier,
464
+ compositionCompareFile: c.identifier,
465
+ })
466
+ : buildUpdatedUrlFromQuery(query, location.pathname, {});
467
+ const onClick = compareState?.controlled
468
+ ? (id, e) => {
469
+ compareHooks?.onClick?.(id, e);
470
+ baseHooks?.onClick?.(id, e);
471
+ }
472
+ : undefined;
473
+ return { id: c.identifier, label: c.displayName, href, onClick, tag };
474
+ });
475
+ }, [
476
+ baseCompositions,
477
+ compareCompositions,
478
+ baseCompositionIds,
479
+ compareCompositionIds,
480
+ compareState?.controlled,
481
+ compareHooks,
482
+ baseHooks,
483
+ query,
484
+ location.pathname,
485
+ ]);
123
486
 
124
- const selectedCompareDropdown = selectedCompareComp && {
125
- id: selectedCompareComp.identifier,
126
- label: selectedCompareComp.displayName,
127
- };
487
+ const selectedCompareDropdown = useMemo(() => {
488
+ const found =
489
+ compositionsDropdownSource.find((item) => item.id === selectedCompareComp?.identifier) ||
490
+ compositionsDropdownSource.find((item) => item.id === selectedBaseComp?.identifier);
491
+ if (found) return found;
492
+ if (requestedCompositionId) return { id: requestedCompositionId, label: requestedCompositionId, tag: 'Missing' };
493
+ return undefined;
494
+ }, [
495
+ compositionsDropdownSource,
496
+ selectedCompareComp?.identifier,
497
+ selectedBaseComp?.identifier,
498
+ requestedCompositionId,
499
+ ]);
500
+
501
+ const baseIdStr = base?.model.id?.toString();
502
+ const compareIdStr = compare?.model.id?.toString();
503
+ useDefaultControlsSchemaResponder(baseIdStr, Boolean(baseIdStr));
504
+ useDefaultControlsSchemaResponder(compareIdStr, Boolean(compareIdStr));
505
+
506
+ const baseCompId = resolveCompositionId(selectedBaseComp, requestedCompositionId);
507
+ const compareCompId = resolveCompositionId(selectedCompareComp, requestedCompositionId);
508
+
509
+ const baseChannelKey = useMemo(() => buildChannelKey('base', baseIdStr, baseCompId), [baseIdStr, baseCompId]);
510
+ const compareChannelKey = useMemo(
511
+ () => buildChannelKey('compare', compareIdStr, compareCompId),
512
+ [compareIdStr, compareCompId]
513
+ );
514
+
515
+ const baseQuery = useMemo(() => buildQueryParams(baseChannelKey), [baseChannelKey]);
516
+ const compareQuery = useMemo(() => buildQueryParams(compareChannelKey), [compareChannelKey]);
517
+
518
+ const controlsResetKey = buildControlsResetKey(baseChannelKey, compareChannelKey);
519
+ const hasControlChannels = Boolean(baseChannelKey && compareChannelKey);
520
+
521
+ useEffect(() => {
522
+ setEverHadControls(false);
523
+ setControlsStatus('loading');
524
+ }, [controlsResetKey]);
525
+
526
+ const handleControlsStatusChange = useCallback((status: ControlsStatus) => {
527
+ setControlsStatus(status);
528
+ if (status === 'available') setEverHadControls(true);
529
+ }, []);
530
+
531
+ const showControlsPanel = controlsStatus === 'available' || everHadControls;
532
+
533
+ const baseModel = base?.model;
534
+ const compareModel = compare?.model;
535
+ const stableLabels = useStableControlLabels(baseModel, compareModel, compare?.hasLocalChanges);
128
536
 
129
537
  const BaseLayout = useMemo(() => {
130
- if (base === undefined) {
131
- return null;
132
- }
133
- const baseCompModel = base.model;
134
- const compositionProps = {
135
- forceHeight: undefined,
136
- innerBottomPadding: 50,
137
- ...previewViewProps,
138
- emptyState,
139
- component: baseCompModel,
140
- queryParams: baseCompQueryParams,
141
- selected: selectedCompareComp,
142
- }
538
+ if (!isStableData || !baseChannelKey || !baseModel) return null;
539
+ if (baseMissing) return <MissingComposition compositionId={requestedCompositionId} version="base" />;
143
540
  return (
144
- <div className={styles.subView}>
145
- <CompositionCompareContext.Provider value={{ compositionProps, isBase: true }}>
146
- <CompositionContextProvider queryParams={baseCompositionParams} setQueryParams={setBaseCompositionParams}>
147
- <PreviewView
148
- forceHeight={undefined}
149
- innerBottomPadding={50}
150
- {...previewViewProps}
151
- emptyState={emptyState}
152
- component={baseCompModel}
153
- selected={selectedCompareComp}
154
- queryParams={baseCompQueryParams}
155
- />
156
- </CompositionContextProvider>
157
- </CompositionCompareContext.Provider>
158
- </div>
541
+ <CompositionLayout
542
+ model={baseModel}
543
+ selected={selectedBaseComp}
544
+ queryParams={baseQuery.queryString}
545
+ compositionParams={baseQuery.params}
546
+ previewViewProps={previewViewProps}
547
+ emptyState={emptyState}
548
+ PreviewView={PreviewView}
549
+ contextKey={`base-${baseIdStr}-${baseCompId}`}
550
+ isBase
551
+ />
159
552
  );
160
- }, [base, selectedCompareComp?.identifier]);
553
+ }, [
554
+ isStableData,
555
+ baseModel,
556
+ baseIdStr,
557
+ baseCompId,
558
+ baseChannelKey,
559
+ selectedBaseComp?.identifier,
560
+ baseQuery,
561
+ previewViewProps,
562
+ emptyState,
563
+ baseMissing,
564
+ requestedCompositionId,
565
+ ]);
161
566
 
162
567
  const CompareLayout = useMemo(() => {
163
- if (compare === undefined) {
164
- return null;
165
- }
166
- const compareCompModel = compare.model;
167
- const compositionProps = {
168
- forceHeight: undefined,
169
- innerBottomPadding: 50,
170
- ...previewViewProps,
171
- emptyState,
172
- component: compareCompModel,
173
- queryParams: compareCompQueryParams,
174
- selected: selectedCompareComp,
175
- }
568
+ if (!isStableData || !compareChannelKey || !compareModel) return null;
569
+ if (compareMissing) return <MissingComposition compositionId={requestedCompositionId} version="compare" />;
176
570
  return (
177
- <div className={styles.subView}>
178
- <CompositionCompareContext.Provider value={{ compositionProps, isCompare: true }}>
179
- <CompositionContextProvider queryParams={compareCompositionParams} setQueryParams={setCompareCompositionParams}>
180
- <PreviewView
181
- forceHeight={undefined}
182
- innerBottomPadding={50}
183
- {...previewViewProps}
184
- emptyState={emptyState}
185
- component={compareCompModel}
186
- queryParams={compareCompQueryParams}
187
- selected={selectedCompareComp}
188
- />
189
- </CompositionContextProvider>
190
- </CompositionCompareContext.Provider>
191
-
192
- </div>
571
+ <CompositionLayout
572
+ model={compareModel}
573
+ selected={selectedCompareComp}
574
+ queryParams={compareQuery.queryString}
575
+ compositionParams={compareQuery.params}
576
+ previewViewProps={previewViewProps}
577
+ emptyState={emptyState}
578
+ PreviewView={PreviewView}
579
+ contextKey={`compare-${compareIdStr}-${compareCompId}`}
580
+ isCompare
581
+ />
193
582
  );
194
- }, [compare, selectedCompareComp?.identifier]);
583
+ }, [
584
+ isStableData,
585
+ compareModel,
586
+ compareIdStr,
587
+ compareCompId,
588
+ compareChannelKey,
589
+ selectedCompareComp?.identifier,
590
+ compareQuery,
591
+ previewViewProps,
592
+ emptyState,
593
+ compareMissing,
594
+ requestedCompositionId,
595
+ ]);
195
596
 
196
- const CompositionToolbar = () => {
197
- if (!base && !compare) {
198
- return null;
199
- }
597
+ const toggleControls = useCallback(() => setControlsOpen((x) => !x), []);
598
+ const handleHeaderKeyDown = useCallback(
599
+ (e: React.KeyboardEvent) => {
600
+ if (e.key === 'Enter' || e.key === ' ') toggleControls();
601
+ },
602
+ [toggleControls]
603
+ );
200
604
 
201
- return (
605
+ if (!base && !compare) return null;
606
+
607
+ const key = `${base?.model.id.toString()}-${compare?.model.id.toString()}-composition-compare`;
608
+
609
+ return (
610
+ <div key={key} className={classNames(styles.container, { [styles.isResizing]: isResizing })}>
611
+ {contextLoading && (
612
+ <div className={styles.loader}>
613
+ <RoundLoader />
614
+ </div>
615
+ )}
202
616
  <div className={styles.toolbar}>
203
617
  <div className={styles.left}>
204
618
  <div className={styles.dropdown}>
@@ -212,22 +626,47 @@ export function CompositionCompare(props: CompositionCompareProps) {
212
626
  <div className={styles.widgets}>{Widgets?.Right}</div>
213
627
  </div>
214
628
  </div>
215
- );
216
- };
217
-
218
- const key = `${componentCompareContext?.base?.model.id.toString()}-${componentCompareContext?.compare?.model.id.toString()}-composition-compare`;
219
-
220
- return (
221
- <React.Fragment key={key}>
222
- {componentCompareContext?.loading && (
223
- <div className={styles.loader}>
224
- <RoundLoader />
629
+ <div className={styles.compareLayout}>
630
+ <div className={styles.compareMain}>
631
+ <CompareSplitLayoutPreset base={BaseLayout} compare={CompareLayout} />
225
632
  </div>
226
- )}
227
- <CompositionToolbar />
228
- <CompareSplitLayoutPreset base={BaseLayout} compare={CompareLayout}
229
- />
230
- </React.Fragment>
633
+ {hasControlChannels && (
634
+ <div
635
+ ref={panelRef}
636
+ className={styles.controlsPanel}
637
+ style={showControlsPanel ? (isControlsOpen ? { height: panelHeight } : undefined) : { display: 'none' }}
638
+ >
639
+ <div
640
+ className={styles.controlsResizeHandle}
641
+ onMouseDown={handleResizeStart}
642
+ role="separator"
643
+ aria-orientation="horizontal"
644
+ />
645
+ <div
646
+ className={styles.controlsPanelHeader}
647
+ onClick={toggleControls}
648
+ onKeyDown={handleHeaderKeyDown}
649
+ role="button"
650
+ tabIndex={0}
651
+ >
652
+ <Icon of={isControlsOpen ? 'fat-arrow-down' : 'fat-arrow-up'} className={styles.controlsArrow} />
653
+ <span className={styles.controlsPanelTitle}>Live controls</span>
654
+ </div>
655
+ <div className={styles.controlsPanelContent} style={{ display: isControlsOpen ? undefined : 'none' }}>
656
+ <LiveControlsDiffPanel
657
+ resetKey={controlsResetKey}
658
+ baseChannel={baseChannelKey}
659
+ compareChannel={compareChannelKey}
660
+ commonLabel={stableLabels.common}
661
+ baseLabel={stableLabels.base}
662
+ compareLabel={stableLabels.compare}
663
+ showEmptyState={false}
664
+ onStatusChange={handleControlsStatusChange}
665
+ />
666
+ </div>
667
+ </div>
668
+ )}
669
+ </div>
670
+ </div>
231
671
  );
232
672
  }
233
-