| 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 | |
| 8 | import type {Block, LineIdx} from 'shared/diff'; |
| 9 | import type {FileRev, FileStackState} from '../fileStackState'; |
| 10 | import type {Mode} from './FileStackEditorLines'; |
| 11 | import type {RangeInfo} from './TextEditable'; |
| 12 | |
| 13 | import deepEqual from 'fast-deep-equal'; |
| 14 | import {Set as ImSet, List, Range} from 'immutable'; |
| 15 | import {Checkbox} from 'isl-components/Checkbox'; |
| 16 | import React, {useEffect, useLayoutEffect, useRef, useState} from 'react'; |
| 17 | import { |
| 18 | collapseContextBlocks, |
| 19 | readableDiffBlocks as diffBlocks, |
| 20 | mergeBlocks, |
| 21 | splitLines, |
| 22 | } from 'shared/diff'; |
| 23 | import {nullthrows} from 'shared/utils'; |
| 24 | import {CommitTitle} from '../../CommitTitle'; |
| 25 | import {Row, ScrollX, ScrollY} from '../../ComponentUtils'; |
| 26 | import {FlattenLine} from '../../linelog'; |
| 27 | import {max, next, prev} from '../revMath'; |
| 28 | import {computeLinesForFileStackEditor} from './FileStackEditorLines'; |
| 29 | import {TextEditable} from './TextEditable'; |
| 30 | |
| 31 | import './FileStackEditor.css'; |
| 32 | |
| 33 | type 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 | |
| 65 | type EditorProps = EditorRowProps & { |
| 66 | /** The rev in the stack to edit. */ |
| 67 | rev: FileRev; |
| 68 | }; |
| 69 | |
| 70 | export 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. */ |
| 235 | function 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 | |
| 491 | export 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 | |
| 519 | function 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 | */ |
| 570 | function 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 | |