23.6 KB733 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import type {Block, LineIdx} from 'shared/diff';
9import type {FileRev, FileStackState} from '../fileStackState';
10import type {Mode} from './FileStackEditorLines';
11import type {RangeInfo} from './TextEditable';
12
13import deepEqual from 'fast-deep-equal';
14import {Set as ImSet, List, Range} from 'immutable';
15import {Checkbox} from 'isl-components/Checkbox';
16import React, {useEffect, useLayoutEffect, useRef, useState} from 'react';
17import {
18 collapseContextBlocks,
19 readableDiffBlocks as diffBlocks,
20 mergeBlocks,
21 splitLines,
22} from 'shared/diff';
23import {nullthrows} from 'shared/utils';
24import {CommitTitle} from '../../CommitTitle';
25import {Row, ScrollX, ScrollY} from '../../ComponentUtils';
26import {FlattenLine} from '../../linelog';
27import {max, next, prev} from '../revMath';
28import {computeLinesForFileStackEditor} from './FileStackEditorLines';
29import {TextEditable} from './TextEditable';
30
31import './FileStackEditor.css';
32
33type EditorRowProps = {
34 /**
35 * File stack to edit.
36 *
37 * Note: the editor for rev 1 might want to diff against rev 0 and rev 2,
38 * and might have buttons to move lines to other revs. So it needs to
39 * know the entire stack.
40 */
41 stack: FileStackState;
42
43 /** Function to update the stack. */
44 setStack: (stack: FileStackState) => void;
45
46 /** Function to get the "title" of a rev. */
47 getTitle?: (rev: FileRev) => string;
48
49 /**
50 * Skip editing (or showing) given revs.
51 * This is usually to skip rev 0 (public, empty) if it is absent.
52 * In the side-by-side mode, rev 0 is shown it it is an existing empty file
53 * (introduced by a previous public commit). rev 0 is not shown if it is
54 * absent, aka. rev 1 added the file.
55 */
56 skip?: (rev: FileRev) => boolean;
57
58 /** Diff mode. */
59 mode: Mode;
60
61 /** Whether to enable text editing. This will disable conflicting features. */
62 textEdit: boolean;
63};
64
65type EditorProps = EditorRowProps & {
66 /** The rev in the stack to edit. */
67 rev: FileRev;
68};
69
70export function FileStackEditor(props: EditorProps) {
71 const mainContentRef = useRef<HTMLPreElement | null>(null);
72 const [expandedLines, setExpandedLines] = useState<ImSet<LineIdx>>(ImSet);
73 const [selectedLineIds, setSelectedLineIds] = useState<ImSet<string>>(ImSet);
74 const [widthStyle, setWidthStyle] = useState<string>('unset');
75 const {stack, rev, setStack, mode} = props;
76 const readOnly = rev === 0;
77 const textEdit = !readOnly && props.textEdit;
78 const rangeInfos: RangeInfo[] = [];
79
80 // Selection change is a document event, not a <pre> event.
81 useEffect(() => {
82 const handleSelect = () => {
83 const selection = window.getSelection();
84 if (
85 textEdit ||
86 selection == null ||
87 mainContentRef.current == null ||
88 !mainContentRef.current.contains(selection.anchorNode)
89 ) {
90 setSelectedLineIds(ids => (ids.isEmpty() ? ids : ImSet()));
91 return;
92 }
93 const divs = mainContentRef.current.querySelectorAll<HTMLDivElement>('div[data-sel-id]');
94 const selIds: Array<string> = [];
95 for (const div of divs) {
96 const child = div.lastChild;
97 if (child && selection.containsNode(child, true)) {
98 selIds.push(nullthrows(div.dataset.selId));
99 }
100 }
101 setSelectedLineIds(ImSet(selIds));
102 };
103 document.addEventListener('selectionchange', handleSelect);
104 return () => {
105 document.removeEventListener('selectionchange', handleSelect);
106 };
107 }, [textEdit]);
108
109 if (mode === 'unified-stack') {
110 return null;
111 }
112
113 // Diff with the left side.
114 const bText = stack.getRev(rev);
115 const bLines = splitLines(bText);
116 const aLines = splitLines(stack.getRev(max(prev(rev), 0)));
117 const abBlocks = diffBlocks(aLines, bLines);
118
119 const rightMost = rev + 1 >= stack.revLength;
120
121 // For side-by-side diff, we also need to diff with the right side.
122 let cbBlocks: Array<Block> = [];
123 let blocks = abBlocks;
124 if (!rightMost && mode === 'side-by-side-diff') {
125 const cText = stack.getRev(next(rev));
126 const cLines = splitLines(cText);
127 cbBlocks = diffBlocks(cLines, bLines);
128 blocks = mergeBlocks(abBlocks, cbBlocks);
129 }
130
131 const {leftGutter, leftButtons, mainContent, rightGutter, rightButtons} =
132 computeLinesForFileStackEditor(
133 stack,
134 setStack,
135 rev,
136 mode,
137 aLines,
138 bLines,
139 undefined,
140 undefined,
141 abBlocks,
142 cbBlocks,
143 blocks,
144 expandedLines,
145 setExpandedLines,
146 selectedLineIds,
147 rangeInfos,
148 textEdit,
149 readOnly,
150 );
151
152 const ribbons: Array<JSX.Element> = [];
153 if (mode === 'side-by-side-diff' && rev > 0) {
154 abBlocks.forEach(([sign, [a1, a2, b1, b2]]) => {
155 if (sign === '!') {
156 ribbons.push(
157 <Ribbon
158 a1={`${rev - 1}-${a1}r`}
159 a2={`${rev - 1}-${a2 - 1}r`}
160 b1={`${rev}-${b1}l`}
161 b2={`${rev}-${b2 - 1}l`}
162 outerContainerClass="file-stack-editor-outer-scroll-y"
163 innerContainerClass="file-stack-editor"
164 key={b1}
165 className={b1 === b2 ? 'del' : a1 === a2 ? 'add' : 'change'}
166 />,
167 );
168 }
169 });
170 }
171
172 const handleTextChange = (value: string) => {
173 const newStack = stack.editText(rev, value);
174 setStack(newStack);
175 };
176
177 const handleXScroll: React.UIEventHandler<HTMLDivElement> = e => {
178 // Dynamically decide between 'width: fit-content' and 'width: unset'.
179 // Affects the position of the [->] "move right" button and the width
180 // of the line background for LONG LINES.
181 //
182 // |ScrollX width|
183 // ------------------------------------------------------------------------
184 // |Editor width | <- width: unset && scrollLeft == 0
185 // |Text width - could be long| text could be longer
186 // | [->]| "move right" button is visible
187 // ------------------------------------------------------------------------
188 // |Editor width | <- width: unset && scrollLeft > 0
189 // |+/- highlight| +/- background covers partial text
190 // | [->]| "move right" at wrong position
191 // ------------------------------------------------------------------------
192 // |Editor width | <- width: fit-content && scrollLeft > 0
193 // |Text width - could be long| long text width = editor width
194 // |+/- highlight | +/- background covers all text
195 // | [->]| "move right" at the right side of text
196 //
197 const newWidthStyle = e.currentTarget?.scrollLeft > 0 ? 'fit-content' : 'unset';
198 setWidthStyle(newWidthStyle);
199 };
200
201 const mainStyle: React.CSSProperties = {width: widthStyle};
202 const mainContentPre = (
203 <pre className="main-content" style={mainStyle} ref={mainContentRef}>
204 {mainContent}
205 </pre>
206 );
207
208 const showLineButtons = !textEdit && !readOnly && mode === 'unified-diff';
209
210 return (
211 <div className="file-stack-editor-ribbon-no-clip">
212 {ribbons}
213 <div>
214 <Row className="file-stack-editor">
215 {showLineButtons && <pre className="column-left-buttons">{leftButtons}</pre>}
216 <pre className="column-left-gutter">{leftGutter}</pre>
217 <ScrollX hideBar={true} size={500} maxSize={500} onScroll={handleXScroll}>
218 {textEdit ? (
219 <TextEditable value={bText} rangeInfos={rangeInfos} onTextChange={handleTextChange}>
220 {mainContentPre}
221 </TextEditable>
222 ) : (
223 mainContentPre
224 )}
225 </ScrollX>
226 <pre className="column-right-gutter">{rightGutter}</pre>
227 {showLineButtons && <pre className="column-right-buttons">{rightButtons}</pre>}
228 </Row>
229 </div>
230 </div>
231 );
232}
233
234/** The unified stack view is different from other views. */
235function FileStackEditorUnifiedStack(props: EditorRowProps) {
236 type ClickPosition = {
237 rev: FileRev;
238 lineIdx: LineIdx;
239 checked?: boolean;
240 };
241 const [clickStart, setClickStart] = useState<ClickPosition | null>(null);
242 const [clickEnd, setClickEnd] = useState<ClickPosition | null>(null);
243 const [expandedLines, setExpandedLines] = useState<ImSet<LineIdx>>(ImSet);
244
245 const {stack, setStack, textEdit} = props;
246 const {skip, getTitle} = getSkipGetTitleOrDefault(props);
247
248 const rangeInfos: Array<RangeInfo> = [];
249
250 const lines = stack.convertToFlattenLines();
251 const revs = stack.revs().filter(rev => !skip(rev));
252 const lastRev = revs.at(-1) ?? -1;
253
254 // RangeInfo handling required by TextEditable.
255 let start = 0;
256 const nextRangeId = (len: number): number => {
257 const id = rangeInfos.length;
258 const end = start + len;
259 rangeInfos.push({start, end});
260 start = end;
261 return id;
262 };
263
264 // Append `baseName` with `color${rev % 4}`.
265 const getColorClassName = (baseName: string, rev: number): string => {
266 const colorIdx = rev % 4;
267 return `${baseName} color${colorIdx}`;
268 };
269
270 // Header. Commit titles.
271 const headerRows = revs.map(rev => {
272 const padTds = revs.map(rev2 => (
273 <th key={rev2} className={getColorClassName('pad', Math.min(rev2, rev))}></th>
274 ));
275 const title = getTitle(rev);
276 return (
277 <tr key={rev}>
278 {padTds}
279 <th className={getColorClassName('commit-title', rev)}>
280 <CommitTitle commitMessage={title} tooltipPlacement="left" />
281 </th>
282 </tr>
283 );
284 });
285
286 // Checkbox range selection.
287 const getSelRanges = (start: ClickPosition | null, end: ClickPosition | null) => {
288 // Minimal number sort. Note Array.sort is a string sort.
289 const sort2 = (a: number, b: number) => (a < b ? [a, b] : [b, a]);
290
291 // Selected range to highlight.
292 let lineRange = Range(0, 0);
293 let revRange = Range(0, 0);
294 if (start != null && end != null) {
295 const [rev1, rev2] = sort2(start.rev, end.rev);
296 // Skip rev 0 (public, immutable).
297 revRange = Range(Math.max(rev1, 1), rev2 + 1);
298 const [lineIdx1, lineIdx2] = sort2(start.lineIdx, end.lineIdx);
299 lineRange = Range(lineIdx1, lineIdx2 + 1);
300 }
301 return [lineRange, revRange];
302 };
303 const [selLineRange, selRevRange] = getSelRanges(clickStart, clickEnd ?? clickStart);
304
305 const handlePointerDown = (
306 lineIdx: LineIdx,
307 rev: FileRev,
308 checked: boolean,
309 e: React.PointerEvent,
310 ) => {
311 if (e.isPrimary) {
312 setClickStart({lineIdx, rev, checked});
313 }
314 };
315 const handlePointerMove = (lineIdx: LineIdx, rev: FileRev, e: React.PointerEvent) => {
316 if (e.isPrimary && clickStart != null) {
317 const newClickEnd = {lineIdx, rev, checked: false};
318 setClickEnd(v => (deepEqual(v, newClickEnd) ? v : newClickEnd));
319 }
320 };
321 const handlePointerUp = (lineIdx: LineIdx, rev: FileRev, e: React.PointerEvent) => {
322 setClickEnd(null);
323 if (e.isPrimary && clickStart != null) {
324 const [lineRange, revRange] = getSelRanges(clickStart, {lineIdx, rev});
325 setClickStart(null);
326 const newStack = stack.mapAllLines((line, i) => {
327 if (lineRange.contains(i)) {
328 const newRevs = clickStart.checked
329 ? line.revs.union(revRange)
330 : line.revs.subtract(revRange);
331 return line.set('revs', newRevs);
332 } else {
333 return line;
334 }
335 });
336 setStack(newStack);
337 }
338 };
339
340 // Context line analysis. We "abuse" the `collapseContextBlocks` by faking the `blocks`.
341 const blocks: Array<Block> = [];
342 const pushSign = (sign: '!' | '=', end: LineIdx) => {
343 const lastBlock = blocks.at(-1);
344 if (lastBlock == null) {
345 blocks.push([sign, [0, end, 0, end]]);
346 } else if (lastBlock[0] === sign) {
347 lastBlock[1][1] = lastBlock[1][3] = end;
348 } else {
349 blocks.push([sign, [lastBlock[1][1], end, lastBlock[1][3], end]]);
350 }
351 };
352 lines.forEach((line, i) => {
353 const sign = line.revs.size >= revs.length ? '=' : '!';
354 pushSign(sign, i + 1);
355 });
356 const collapsedBlocks = collapseContextBlocks(blocks, (_a, b) => expandedLines.contains(b));
357
358 const handleContextExpand = (b1: LineIdx, b2: LineIdx) => {
359 const newSet = expandedLines.union(Range(b1, b2));
360 setExpandedLines(newSet);
361 };
362
363 // Body. Checkboxes + Line content, or "~~~~" context button.
364 const bodyRows: JSX.Element[] = [];
365 collapsedBlocks.forEach(([sign, [, , b1, b2]]) => {
366 if (sign === '~') {
367 const checkboxes = revs.map(rev => (
368 <td key={rev} className={getColorClassName('', rev)}></td>
369 ));
370
371 bodyRows.push(
372 <tr key={b1}>
373 {checkboxes}
374 <td className="context-button" onClick={() => handleContextExpand(b1, b2)}>
375 <span> </span>
376 </td>
377 </tr>,
378 );
379
380 if (textEdit) {
381 const len = Range(b1, b2).reduce((acc, i) => acc + nullthrows(lines.get(i)).data.length, 0);
382 nextRangeId(len);
383 }
384
385 return;
386 }
387 for (let i = b1; i < b2; ++i) {
388 const line = nullthrows(lines.get(i));
389 const checkboxes = revs.map(rev => {
390 const checked = line.revs.contains(rev);
391 let className = 'checkbox' + (rev > 0 ? ' mutable' : ' immutable');
392 if (selLineRange.contains(i) && selRevRange.contains(rev)) {
393 className += clickStart?.checked ? ' add' : ' del';
394 }
395 return (
396 <td
397 key={rev}
398 className={getColorClassName(className, rev)}
399 onPointerDown={e => handlePointerDown(i, rev, !checked, e)}
400 onPointerMove={e => handlePointerMove(i, rev, e)}
401 onPointerUp={e => handlePointerUp(i, rev, e)}
402 onDragStart={e => e.preventDefault()}>
403 <Checkbox
404 tabIndex={-1}
405 disabled={rev === 0}
406 checked={checked}
407 style={{pointerEvents: 'none'}}
408 />
409 </td>
410 );
411 });
412 let tdClass = 'line';
413 if (!line.revs.has(lastRev)) {
414 tdClass += ' del';
415 } else if (line.revs.size < revs.length) {
416 tdClass += ' change';
417 }
418 const rangeId = textEdit ? nextRangeId(line.data.length) : undefined;
419 bodyRows.push(
420 <tr key={i}>
421 {checkboxes}
422 <td className={tdClass}>
423 <span className="line" data-range-id={rangeId}>
424 {line.data}
425 </span>
426 </td>
427 </tr>,
428 );
429 }
430 });
431
432 let editor = (
433 <table className="file-unified-stack-editor">
434 <thead>{headerRows}</thead>
435 <tbody>{bodyRows}</tbody>
436 </table>
437 );
438
439 if (textEdit) {
440 const textLines = lines.map(l => l.data).toArray();
441 const text = textLines.join('');
442 const handleTextChange = (newText: string) => {
443 const immutableRev = 0 as FileRev;
444 const immutableRevs: ImSet<FileRev> = ImSet([immutableRev]);
445 const newTextLines = splitLines(newText);
446 const blocks = diffBlocks(textLines, newTextLines);
447 const newFlattenLines: List<FlattenLine> = List<FlattenLine>().withMutations(mut => {
448 let flattenLines = mut;
449 blocks.forEach(([sign, [a1, a2, b1, b2]]) => {
450 if (sign === '=') {
451 flattenLines = flattenLines.concat(lines.slice(a1, a2));
452 } else if (sign === '!') {
453 // Plain text does not have "revs" info.
454 // We just reuse the last line on the a-side. This should work fine for
455 // single-line insertion or edits.
456 const fallbackRevs: ImSet<FileRev> = (lines
457 .get(Math.max(a1, a2 - 1))
458 ?.revs?.delete(immutableRev) ?? ImSet()) as ImSet<FileRev>;
459 // Public (immutableRev, rev 0) lines cannot be deleted. Enforce that.
460 const aLines = Range(a1, a2)
461 .map(ai => lines.get(ai))
462 .filter(l => l != null && l.revs.contains(immutableRev))
463 .map(l => (l as FlattenLine).set('revs', immutableRevs));
464 // Newly added lines cannot insert to (immutableRev, rev 0) either.
465 const bLines = Range(b1, b2).map(bi => {
466 const data = newTextLines[bi] ?? '';
467 const ai = bi - b1 + a1;
468 const revs =
469 (ai < a2 ? lines.get(ai)?.revs?.delete(immutableRev) : null) ?? fallbackRevs;
470 return FlattenLine({data, revs});
471 });
472 flattenLines = flattenLines.concat(aLines).concat(bLines);
473 }
474 });
475 return flattenLines;
476 });
477 const newStack = stack.fromFlattenLines(newFlattenLines, stack.revLength);
478 setStack(newStack);
479 };
480
481 editor = (
482 <TextEditable rangeInfos={rangeInfos} value={text} onTextChange={handleTextChange}>
483 {editor}
484 </TextEditable>
485 );
486 }
487
488 return <ScrollY maxSize="calc((100vh / var(--zoom)) - 300px)">{editor}</ScrollY>;
489}
490
491export function FileStackEditorRow(props: EditorRowProps) {
492 if (props.mode === 'unified-stack') {
493 return FileStackEditorUnifiedStack(props);
494 }
495
496 // skip rev 0, the "public" revision for unified diff.
497 const {skip, getTitle} = getSkipGetTitleOrDefault(props);
498 const revs = props.stack
499 .revs()
500 .slice(props.mode === 'unified-diff' ? 1 : 0)
501 .filter(r => !skip(r));
502 return (
503 <ScrollX>
504 <Row className="file-stack-editor-row">
505 {revs.map(rev => {
506 const title = getTitle(rev);
507 return (
508 <div key={rev}>
509 <CommitTitle className="filerev-title" commitMessage={title} />
510 <FileStackEditor rev={rev} {...props} />
511 </div>
512 );
513 })}
514 </Row>
515 </ScrollX>
516 );
517}
518
519function getSkipGetTitleOrDefault(props: EditorRowProps): {
520 skip: (rev: FileRev) => boolean;
521 getTitle: (rev: FileRev) => string;
522} {
523 const skip = props.skip ?? ((rev: FileRev) => rev === 0);
524 const getTitle = props.getTitle ?? (() => '');
525 return {skip, getTitle};
526}
527
528/**
529 * The "connector" between two editors.
530 *
531 * Takes 4 data-span-id attributes:
532 *
533 * +------------+ +------------+
534 * | containerA | | containerB |
535 * | +----+~~~~~~~~+----+ |
536 * | | a1 | | b1 | |
537 * | +----+ +----+ |
538 * | | .. | Ribbon | .. | |
539 * | +----+ +----+ |
540 * | | a2 | | b2 | |
541 * | +----+~~~~~~~~+----+ |
542 * | | | |
543 * +------------+ +------------+
544 *
545 * The ribbon is positioned relative to (outer) containerB,
546 * the editor on the right side.
547 *
548 * The ribbon position will be recalculated if either containerA
549 * or containerB gets resized or scrolled. Note there are inner
550 * and outer containers. The scroll check is on the outer container
551 * with the `overflow-y: auto`. The resize check is on the inner
552 * container, since the outer container remains the same size
553 * once overflowed.
554 *
555 * The ribbons are drawn outside the scroll container, and need
556 * another container to have the `overflow: visible` behavior,
557 * like:
558 *
559 * <div style={{overflow: 'visible', position: 'relative'}}>
560 * <Ribbon />
561 * <ScrollY className="outerContainer">
562 * <Something className="innerContainer" />
563 * </ScrollY>
564 * </div>
565 *
566 * If one of a1 and a2 is missing, the a-side range is then
567 * considered zero-height. This is useful for pure deletion
568 * or insertion. Same for b1 and b2.
569 */
570function Ribbon(props: {
571 a1: string;
572 a2: string;
573 b1: string;
574 b2: string;
575 outerContainerClass: string;
576 innerContainerClass: string;
577 className: string;
578}) {
579 type RibbonPos = {
580 top: number;
581 width: number;
582 height: number;
583 path: string;
584 };
585 const [pos, setPos] = useState<RibbonPos | null>(null);
586 type E = HTMLElement;
587
588 type Containers = {
589 resize: E[];
590 scroll: E[];
591 };
592
593 useLayoutEffect(() => {
594 // Get the container elements and recaluclate positions.
595 // Returns an empty array if the containers are not found.
596 const repositionAndGetContainers = (): Containers | undefined => {
597 // Find a1, a2, b1, b2. a2 and b2 are nullable.
598 const select = (spanId: string): E | null =>
599 spanId === ''
600 ? null
601 : document.querySelector(`.${props.outerContainerClass} [data-span-id="${spanId}"]`);
602 const [a1, a2, b1, b2] = [props.a1, props.a2, props.b1, props.b2].map(select);
603 const aEither = a1 ?? a2;
604 const bEither = b1 ?? b2;
605 if (aEither == null || bEither == null) {
606 return;
607 }
608
609 // Find containers.
610 const findContainer = (span: E, className: string): E | null => {
611 for (let e: E | null = span; e != null; e = e.parentElement) {
612 if (e.classList.contains(className)) {
613 return e;
614 }
615 }
616 return null;
617 };
618 const [outerA, outerB] = [aEither, bEither].map(e =>
619 findContainer(e, props.outerContainerClass),
620 );
621 const [innerA, innerB] = [aEither, bEither].map(e =>
622 findContainer(e, props.innerContainerClass),
623 );
624 if (outerA == null || outerB == null || innerA == null || innerB == null) {
625 return;
626 }
627
628 // Recalculate positions. a2Rect and b2Rect are nullable.
629 let newPos: RibbonPos | null = null;
630 const [outerARect, outerBRect] = [outerA, outerB].map(e => e.getBoundingClientRect());
631 const [a1Rect, a2Rect, b1Rect, b2Rect] = [a1, a2, b1, b2].map(
632 e => e && e.getBoundingClientRect(),
633 );
634 const aTop = a1Rect?.top ?? a2Rect?.bottom;
635 const bTop = b1Rect?.top ?? b2Rect?.bottom;
636 const aBottom = a2Rect?.bottom ?? aTop;
637 const bBottom = b2Rect?.bottom ?? bTop;
638 const aRight = a1Rect?.right ?? a2Rect?.right;
639 const bLeft = b1Rect?.left ?? b2Rect?.left;
640
641 if (
642 aTop != null &&
643 bTop != null &&
644 aBottom != null &&
645 bBottom != null &&
646 aRight != null &&
647 bLeft != null
648 ) {
649 const top = Math.min(aTop, bTop) - outerBRect.top;
650 const width = bLeft - aRight;
651 const ay1 = Math.max(aTop - bTop, 0);
652 const by1 = Math.max(bTop - aTop, 0);
653 const height = Math.max(aBottom, bBottom) - Math.min(aTop, bTop);
654 const ay2 = ay1 + aBottom - aTop;
655 const by2 = by1 + bBottom - bTop;
656 const mid = width / 2;
657
658 // Discard overflow position.
659 if (
660 top >= 0 &&
661 top + Math.max(ay2, by2) <= Math.max(outerARect.height, outerBRect.height)
662 ) {
663 const path = [
664 `M 0 ${ay1}`,
665 `C ${mid} ${ay1}, ${mid} ${by1}, ${width} ${by1}`,
666 `L ${width} ${by2}`,
667 `C ${mid} ${by2}, ${mid} ${ay2}, 0 ${ay2}`,
668 `L 0 ${ay1}`,
669 ].join(' ');
670 newPos = {
671 top,
672 width,
673 height,
674 path,
675 };
676 }
677 }
678
679 setPos(pos => (deepEqual(pos, newPos) ? pos : newPos));
680
681 return {
682 scroll: [outerA, outerB],
683 resize: [innerA, innerB],
684 };
685 };
686
687 // Calculate position now.
688 const containers = repositionAndGetContainers();
689
690 if (containers == null) {
691 return;
692 }
693
694 // Observe resize and scrolling changes of the container.
695 const observer = new ResizeObserver(() => repositionAndGetContainers());
696 const handleScroll = () => {
697 repositionAndGetContainers();
698 };
699 containers.resize.forEach(c => observer.observe(c));
700 containers.scroll.forEach(c => c.addEventListener('scroll', handleScroll));
701
702 return () => {
703 observer.disconnect();
704 containers.scroll.forEach(c => c.removeEventListener('scroll', handleScroll));
705 };
706 }, [
707 props.a1,
708 props.a2,
709 props.b1,
710 props.b2,
711 props.outerContainerClass,
712 props.innerContainerClass,
713 props.className,
714 ]);
715
716 if (pos == null) {
717 return null;
718 }
719
720 const style: React.CSSProperties = {
721 top: pos.top,
722 left: 1 - pos.width,
723 width: pos.width,
724 height: pos.height,
725 };
726
727 return (
728 <svg className={`ribbon ${props.className}`} style={style}>
729 <path d={pos.path} />
730 </svg>
731 );
732}
733