Something went wrong. Try again.
Monorepo for Tangled tangled.org
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292<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}