<script lang="ts">
interface Props {
container: HTMLElement | undefined;
}
let { container }: Props = $props();
type Edge = "top" | "bottom";
type Rect = { top: number; left: number; width: number; height: number };
type Point = { x: number; y: number };
type Press = {
edge: Edge;
upper: HTMLElement;
lower: HTMLElement;
origin: HTMLElement | undefined;
pointerId: number;
y: number;
started: boolean;
};
type Grip = { edge: Edge; upper: HTMLElement; lower: HTMLElement };
type Span = [HTMLElement | undefined, HTMLElement | undefined];
const HANDLE = "data-line-handle";
const INSET = 4;
const THRESHOLD = 3;
let hovered: HTMLElement | undefined;
let grips: Partial<Record<Edge, Grip>> = {};
let press: Press | undefined;
let anchor: HTMLElement | undefined;
let observer: MutationObserver | undefined;
let observed: Element | undefined;
let active = $state<Edge | undefined>();
let box = $state.raw<Rect | undefined>();
let top = $state.raw<Rect | undefined>();
let bottom = $state.raw<Rect | undefined>();
const isNumber = (node: EventTarget): node is HTMLElement =>
node instanceof HTMLElement && node.hasAttribute("data-column-number");
const isHandle = (node: EventTarget): node is Element =>
node instanceof Element && node.hasAttribute(HANDLE);
const isUtility = (node: EventTarget) =>
node instanceof Element &&
node.matches("[data-gutter-utility-slot], [data-utility-button]");
const inRange = (cell: HTMLElement) => {
const mark = cell.getAttribute("data-selected-line");
return mark !== null && mark !== "single";
};
const neighbour = (cell: HTMLElement, edge: Edge): HTMLElement | undefined => {
const step = (node: Element) =>
edge === "top" ? node.previousElementSibling : node.nextElementSibling;
for (let sibling = step(cell); sibling; sibling = step(sibling)) {
if (isNumber(sibling)) return sibling;
}
return undefined;
};
const selectionEnd = (cell: HTMLElement, mark: "first" | "last") =>
cell.parentElement?.querySelector<HTMLElement>(
`:scope > [data-column-number][data-selected-line="${mark}"]`
) ?? undefined;
const span = (cell: HTMLElement): Span =>
inRange(cell) ? [selectionEnd(cell, "first"), selectionEnd(cell, "last")] : [cell, cell];
const toGrip = (edge: Edge, [upper, lower]: Span): Grip | undefined =>
upper && lower ? { edge, upper, lower } : undefined;
const endsAt = (cell: HTMLElement | undefined, mark: "first" | "last") => {
const value = cell?.getAttribute("data-selected-line");
return value === mark || value === "single";
};
const centre = (cell: HTMLElement): Point => {
const bounds = cell.getBoundingClientRect();
return { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 };
};
const rect = (cell: HTMLElement, frame: DOMRect): Rect => {
const bounds = cell.getBoundingClientRect();
const style = getComputedStyle(cell);
const start = parseFloat(style.paddingLeft);
const end = parseFloat(style.paddingRight);
return {
top: bounds.top - frame.top,
left: bounds.left - frame.left + start - INSET,
width: bounds.width - start - end + 2 * INSET,
height: bounds.height
};
};
const watch = (gutter: Element | undefined) => {
if (gutter === observed) return;
observer?.disconnect();
observed = gutter;
if (gutter) {
observer?.observe(gutter, {
attributes: true,
subtree: true,
attributeFilter: ["data-selected-line"]
});
}
};
const measure = () => {
const cell = anchor ?? hovered;
if (!container || !cell?.isConnected) {
watch(undefined);
grips = {};
box = top = bottom = undefined;
return;
}
watch(cell.parentElement ?? undefined);
const frame = container.getBoundingClientRect();
const [first, last] = span(cell);
if (anchor) active = first !== anchor ? "top" : last !== anchor ? "bottom" : active;
box = active || cell.hasAttribute("data-selected-line") ? undefined : rect(cell, frame);
const alone = !anchor && !inRange(cell);
const pick = (edge: Edge, end: HTMLElement | undefined, mark: "first" | "last") => {
const beyond = end && neighbour(end, edge);
if (active !== edge && !beyond) return undefined;
if (alone && beyond && endsAt(beyond, mark)) {
return toGrip(edge === "top" ? "bottom" : "top", span(beyond));
}
return toGrip(edge, [first, last]);
};
grips = { top: pick("top", first, "last"), bottom: pick("bottom", last, "first") };
top = grips.top && first ? rect(first, frame) : undefined;
bottom = grips.bottom && last ? rect(last, frame) : undefined;
};
const replay = (
type: "pointerdown" | "pointermove" | "pointerup",
target: EventTarget,
at: Point,
event: PointerEvent
) => {
target.dispatchEvent(
new PointerEvent(type, {
bubbles: true,
composed: true,
cancelable: true,
pointerId: event.pointerId,
pointerType: event.pointerType,
isPrimary: event.isPrimary,
button: type === "pointermove" ? -1 : 0,
buttons: type === "pointerup" ? 0 : 1,
clientX: at.x,
clientY: at.y
})
);
};
const grab = (event: PointerEvent) => {
if (!event.isTrusted || event.button !== 0 || event.pointerType === "touch") return;
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) return;
const path = event.composedPath();
if (path.some(isUtility)) return;
const handle = path.find(isHandle);
const origin = handle ? undefined : path.find(isNumber);
if (!handle && !(origin && inRange(origin))) return;
const visual: Edge = handle?.getAttribute(HANDLE) === "top" ? "top" : "bottom";
const grip = origin ? toGrip(visual, span(origin)) : grips[visual];
if (!grip) return;
event.preventDefault();
event.stopPropagation();
press = {
...grip,
origin,
pointerId: event.pointerId,
y: event.clientY,
started: false
};
if (handle) active = visual;
};
const follow = (event: PointerEvent) => {
const current = press;
if (!event.isTrusted || !current || current.started) return;
if (event.pointerId !== current.pointerId) return;
if (Math.abs(event.clientY - current.y) < THRESHOLD) return;
if (current.origin) current.edge = event.clientY < current.y ? "top" : "bottom";
const grabbed = current.edge === "top" ? current.upper : current.lower;
const row = (current.origin ?? grabbed).getBoundingClientRect();
if (event.clientY >= row.top && event.clientY < row.bottom) return;
current.started = true;
anchor = current.edge === "top" ? current.lower : current.upper;
active = current.edge;
replay("pointerdown", anchor, centre(anchor), event);
replay("pointermove", document, { x: event.clientX, y: event.clientY }, event);
measure();
};
const finish = (event: PointerEvent, click: boolean) => {
const current = press;
if (!event.isTrusted || !current || event.pointerId !== current.pointerId) return;
press = anchor = undefined;
active = undefined;
if (click && !current.started && current.origin?.isConnected) {
const at = centre(current.origin);
replay("pointerdown", current.origin, at, event);
replay("pointerup", document, at, event);
}
measure();
};
$effect(() => {
const host = container;
if (!host) return;
observer = new MutationObserver(measure);
let frame = 0;
const sync = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(measure);
};
const track = (event: PointerEvent) => {
if (press) return;
const path = event.composedPath();
if (path.some(isHandle)) return;
const cell = event.pointerType === "touch" ? undefined : path.find(isNumber);
if (cell === undefined && hovered === undefined) return;
hovered = cell;
sync();
};
const leave = () => {
if (press) return;
hovered = undefined;
sync();
};
const release = (event: PointerEvent) => finish(event, true);
const cancel = (event: PointerEvent) => finish(event, false);
host.addEventListener("pointermove", track, { passive: true });
host.addEventListener("pointerleave", leave);
host.addEventListener("pointerdown", grab, { capture: true });
document.addEventListener("pointermove", follow);
document.addEventListener("pointerup", release);
document.addEventListener("pointercancel", cancel);
window.addEventListener("scroll", sync, { passive: true, capture: true });
window.addEventListener("resize", sync);
return () => {
cancelAnimationFrame(frame);
observer?.disconnect();
observer = observed = undefined;
host.removeEventListener("pointermove", track);
host.removeEventListener("pointerleave", leave);
host.removeEventListener("pointerdown", grab, { capture: true });
document.removeEventListener("pointermove", follow);
document.removeEventListener("pointerup", release);
document.removeEventListener("pointercancel", cancel);
window.removeEventListener("scroll", sync, { capture: true });
window.removeEventListener("resize", sync);
};
});
</script>
{#snippet handle(edge: Edge, y: number, at: Rect)}
<div
data-line-handle={edge}
aria-hidden="true"
class={[
"hit-area-y-1 absolute z-10 h-px -translate-y-1/2 rounded-xs transition-[opacity,background-color] duration-150 ease-out-strong starting:opacity-0",
active ? "pointer-events-none" : "cursor-grab hover:bg-alpha-dark-45",
active === edge ? "bg-border-focus" : "bg-alpha-dark-20"
]}
style:top="{y}px"
style:left="{at.left}px"
style:width="{at.width}px"
></div>
{/snippet}
{#if box}
<div
aria-hidden="true"
class="pointer-events-none absolute z-10 rounded-xs bg-alpha-dark-5 transition-opacity duration-150 ease-out-strong starting:opacity-0"
style:top="{box.top + 1.5}px"
style:left="{box.left}px"
style:width="{box.width}px"
style:height="{box.height - 3}px"
></div>
{/if}
{#if top}
{@render handle("top", top.top, top)}
{/if}
{#if bottom}
{@render handle("bottom", bottom.top + bottom.height, bottom)}
{/if}