Skip to content

Commit 035defe

Browse files
committed
Initial drag selection impl
1 parent 5b69246 commit 035defe

File tree

2 files changed

+133
-37
lines changed

2 files changed

+133
-37
lines changed

web/src/lib/components/diff/ConciseDiffView.svelte

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
jumpToSelection = $bindable(false),
3939
}: ConciseDiffViewProps<K> = $props();
4040
41+
const uid = $props.id();
42+
4143
const parsedPatch = $derived.by(() => {
4244
if (rawPatchContent !== undefined) {
4345
return parseSinglePatch(rawPatchContent);
@@ -48,6 +50,8 @@
4850
});
4951
5052
const view = new ConciseDiffViewState({
53+
rootElementId: uid,
54+
5155
patch: box.with(() => parsedPatch),
5256
syntaxHighlighting: box.with(() => syntaxHighlighting),
5357
syntaxHighlightingTheme: box.with(() => syntaxHighlightingTheme),
@@ -187,10 +191,10 @@
187191

188192
{#snippet renderLine(line: PatchLine, hunk: DiffViewerPatchHunk, hunkIndex: number, lineIndex: number)}
189193
{@const lineType = patchLineTypeProps[line.type]}
190-
<div class="bg-[var(--hunk-header-bg)]" {@attach view.selectable(hunk, hunkIndex, line, lineIndex)}>
194+
<div class="bg-[var(--hunk-header-bg)]" data-hunk-idx={hunkIndex} data-line-idx={lineIndex} {@attach view.selectable(hunk, hunkIndex, line, lineIndex)}>
191195
<div class="line-number h-full px-2 select-none {lineType.lineNoClasses}">{getDisplayLineNo(line, line.oldLineNo)}</div>
192196
</div>
193-
<div class="bg-[var(--hunk-header-bg)]" {@attach view.selectable(hunk, hunkIndex, line, lineIndex)}>
197+
<div class="bg-[var(--hunk-header-bg)]" data-hunk-idx={hunkIndex} data-line-idx={lineIndex} {@attach view.selectable(hunk, hunkIndex, line, lineIndex)}>
194198
<div
195199
class="selected-indicator line-number h-full px-2 select-none {lineType.lineNoClasses}"
196200
data-selected={boolAttr(view.isSelected(hunkIndex, lineIndex))}
@@ -200,6 +204,8 @@
200204
</div>
201205
<div
202206
class="selected-indicator w-full pl-[1rem] {lineType.classes}"
207+
data-hunk-idx={hunkIndex}
208+
data-line-idx={lineIndex}
203209
data-selection-start={boolAttr(view.isSelectionStart(hunkIndex, lineIndex))}
204210
data-selection-end={boolAttr(view.isSelectionEnd(hunkIndex, lineIndex))}
205211
{@attach (element) => {
@@ -229,6 +235,7 @@
229235
<div class="flex items-center justify-center bg-neutral-2 p-4"><Spinner /></div>
230236
{:then [rootStyle, diffViewerPatch]}
231237
<div
238+
id={uid}
232239
style={rootStyle}
233240
class="diff-content text-patch-line w-full bg-[var(--editor-bg)] font-mono text-xs leading-[1.25rem] text-[var(--editor-fg)] selection:bg-[var(--select-bg)]"
234241
data-wrap={lineWrap}
@@ -264,6 +271,7 @@
264271
265272
display: grid;
266273
grid-template-columns: min-content min-content auto;
274+
contain: layout style paint;
267275
}
268276
.diff-content[data-wrap="true"] {
269277
word-break: break-all;

web/src/lib/components/diff/concise-diff-view.svelte.ts

Lines changed: 123 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,7 +1129,9 @@ export interface ConciseDiffViewProps<K> {
11291129
jumpToSelection?: boolean;
11301130
}
11311131

1132-
export type ConciseDiffViewStateProps<K> = ReadableBoxedValues<{
1132+
export type ConciseDiffViewStateProps<K> = {
1133+
rootElementId: string;
1134+
} & ReadableBoxedValues<{
11331135
patch: StructuredPatch;
11341136

11351137
syntaxHighlighting: boolean;
@@ -1153,6 +1155,10 @@ export class ConciseDiffViewState<K> {
11531155

11541156
private readonly props: ConciseDiffViewStateProps<K>;
11551157

1158+
// Drag selection state
1159+
private dragState: { hunk: DiffViewerPatchHunk; hunkIdx: number; line: PatchLine; lineIdx: number; didMove: boolean } | null = null;
1160+
private suppressNextClick = false;
1161+
11561162
constructor(props: ConciseDiffViewStateProps<K>) {
11571163
this.props = props;
11581164

@@ -1255,92 +1261,174 @@ export class ConciseDiffViewState<K> {
12551261
}
12561262

12571263
const destroyClick = on(element, "click", (e) => {
1264+
// Only handle click if we didn't just finish dragging
1265+
if (this.suppressNextClick) {
1266+
this.suppressNextClick = false;
1267+
return;
1268+
}
12581269
this.updateSelection(hunk, hunkIdx, line, lineIdx, e.shiftKey);
12591270
});
1271+
1272+
const destroyPointerDown = on(element, "pointerdown", (e: PointerEvent) => {
1273+
// Only start drag on left click without shift key
1274+
if (e.button === 0 && !e.shiftKey) {
1275+
this.startDrag(element, e.pointerId, hunk, hunkIdx, line, lineIdx);
1276+
}
1277+
});
1278+
12601279
return () => {
12611280
destroyClick();
1281+
destroyPointerDown();
12621282
};
12631283
};
12641284
}
12651285

1266-
updateSelection(hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number, shift: boolean) {
1267-
const existingSelection = this.props.selection.current;
1286+
private startDrag(element: HTMLElement, pointerId: number, hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number) {
1287+
this.dragState = { hunk, hunkIdx, line, lineIdx, didMove: false };
1288+
1289+
// Set initial selection
1290+
this.props.selection.current = {
1291+
hunk: hunkIdx,
1292+
start: this.createLineRef(line, lineIdx),
1293+
end: this.createLineRef(line, lineIdx),
1294+
};
1295+
1296+
// Capture pointer events to this element
1297+
element.setPointerCapture(pointerId);
1298+
1299+
const abortController = new AbortController();
1300+
const { signal } = abortController;
1301+
1302+
on(
1303+
element,
1304+
"pointermove",
1305+
(e: PointerEvent) => {
1306+
if (!this.dragState) return;
1307+
1308+
// Get the root element for this diff view
1309+
const rootElement = document.getElementById(this.props.rootElementId);
1310+
if (!rootElement) return;
12681311

1269-
const clicked: LineRef = {
1312+
// Get the element at the pointer position
1313+
const elementAtPoint = document.elementFromPoint(e.clientX, e.clientY);
1314+
if (!elementAtPoint) return;
1315+
1316+
// Only process if the element is within this diff view's root element
1317+
if (!rootElement.contains(elementAtPoint)) return;
1318+
1319+
const lineElement = elementAtPoint.closest("[data-hunk-idx][data-line-idx]") as HTMLElement | null;
1320+
if (!lineElement) return;
1321+
1322+
const currentHunkIdx = Number(lineElement.dataset.hunkIdx);
1323+
const currentLineIdx = Number(lineElement.dataset.lineIdx);
1324+
1325+
// Only allow dragging within the same hunk
1326+
if (currentHunkIdx !== this.dragState.hunkIdx || !Number.isFinite(currentHunkIdx) || !Number.isFinite(currentLineIdx)) {
1327+
return;
1328+
}
1329+
1330+
if (this.dragState) {
1331+
this.dragState.didMove = true;
1332+
}
1333+
this.updateDragSelection(currentLineIdx);
1334+
},
1335+
{ signal },
1336+
);
1337+
1338+
const onDragEnd = (e: PointerEvent) => {
1339+
element.releasePointerCapture(e.pointerId);
1340+
abortController.abort();
1341+
1342+
// Suppress the click event only if we actually moved during the drag
1343+
if (this.dragState?.didMove) {
1344+
this.suppressNextClick = true;
1345+
}
1346+
this.dragState = null;
1347+
};
1348+
1349+
on(element, "pointerup", onDragEnd, { signal });
1350+
on(element, "pointercancel", onDragEnd, { signal });
1351+
}
1352+
1353+
private createLineRef(line: PatchLine, lineIdx: number): LineRef {
1354+
return {
12701355
idx: lineIdx,
12711356
no: line.newLineNo ?? line.oldLineNo!,
12721357
new: line.newLineNo !== undefined,
12731358
};
1359+
}
12741360

1275-
// New selection
1276-
if (!shift || existingSelection === undefined || existingSelection.hunk !== hunkIdx) {
1277-
this.props.selection.current = {
1278-
hunk: hunkIdx,
1279-
start: clicked,
1280-
end: clicked,
1281-
};
1361+
private updateDragSelection(currentLineIdx: number) {
1362+
if (!this.dragState) return;
1363+
1364+
const { hunk, hunkIdx, lineIdx: startIdx } = this.dragState;
1365+
const currentLine = hunk.lines[currentLineIdx];
1366+
1367+
if (currentLine.type === PatchLineType.SPACER || currentLine.type === PatchLineType.HEADER) {
1368+
return;
1369+
}
1370+
1371+
const minIdx = Math.min(startIdx, currentLineIdx);
1372+
const maxIdx = Math.max(startIdx, currentLineIdx);
1373+
1374+
this.props.selection.current = {
1375+
hunk: hunkIdx,
1376+
start: this.createLineRef(hunk.lines[minIdx], minIdx),
1377+
end: this.createLineRef(hunk.lines[maxIdx], maxIdx),
1378+
};
1379+
}
1380+
1381+
updateSelection(hunk: DiffViewerPatchHunk, hunkIdx: number, line: PatchLine, lineIdx: number, shift: boolean) {
1382+
const existingSelection = this.props.selection.current;
1383+
const clicked = this.createLineRef(line, lineIdx);
1384+
1385+
// New selection (no shift or different hunk)
1386+
if (!shift || !existingSelection || existingSelection.hunk !== hunkIdx) {
1387+
this.props.selection.current = { hunk: hunkIdx, start: clicked, end: clicked };
12821388
return;
12831389
}
12841390

1285-
// Shift click idx == start == end: clear selection
1391+
// Shift click on single-line selection: clear selection
12861392
if (existingSelection.start.idx === existingSelection.end.idx && lineIdx === existingSelection.start.idx) {
12871393
this.props.selection.current = undefined;
12881394
return;
12891395
}
12901396

12911397
// Shift click outside selection: expand selection
12921398
if (lineIdx < existingSelection.start.idx) {
1293-
this.props.selection.current = {
1294-
...existingSelection,
1295-
start: clicked,
1296-
};
1399+
this.props.selection.current = { ...existingSelection, start: clicked };
12971400
return;
12981401
}
12991402
if (lineIdx > existingSelection.end.idx) {
1300-
this.props.selection.current = {
1301-
...existingSelection,
1302-
end: clicked,
1303-
};
1403+
this.props.selection.current = { ...existingSelection, end: clicked };
13041404
return;
13051405
}
13061406

1307-
// Shift click inside selection: shrink closest side (start/end) of selection to exclude clicked
1407+
// Shift click inside selection: shrink closest side
13081408
const distToStart = lineIdx - existingSelection.start.idx;
13091409
const distToEnd = existingSelection.end.idx - lineIdx;
13101410

13111411
if (distToStart <= distToEnd) {
13121412
// Shrink from start: move start to line after clicked
13131413
const newStartIdx = lineIdx + 1;
13141414
if (newStartIdx > existingSelection.end.idx) {
1315-
// Selection would be empty, clear it
13161415
this.props.selection.current = undefined;
13171416
return;
13181417
}
1319-
const newStartLine = hunk.lines[newStartIdx];
13201418
this.props.selection.current = {
13211419
...existingSelection,
1322-
start: {
1323-
idx: newStartIdx,
1324-
no: newStartLine.newLineNo ?? newStartLine.oldLineNo!,
1325-
new: newStartLine.newLineNo !== undefined,
1326-
},
1420+
start: this.createLineRef(hunk.lines[newStartIdx], newStartIdx),
13271421
};
13281422
} else {
13291423
// Shrink from end: move end to line before clicked
13301424
const newEndIdx = lineIdx - 1;
13311425
if (newEndIdx < existingSelection.start.idx) {
1332-
// Selection would be empty, clear it
13331426
this.props.selection.current = undefined;
13341427
return;
13351428
}
1336-
const newEndLine = hunk.lines[newEndIdx];
13371429
this.props.selection.current = {
13381430
...existingSelection,
1339-
end: {
1340-
idx: newEndIdx,
1341-
no: newEndLine.newLineNo ?? newEndLine.oldLineNo!,
1342-
new: newEndLine.newLineNo !== undefined,
1343-
},
1431+
end: this.createLineRef(hunk.lines[newEndIdx], newEndIdx),
13441432
};
13451433
}
13461434
}

0 commit comments

Comments
 (0)