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