<script lang="ts">
import { onMount } from "svelte";
import { paintOrder } from "./scroll-marks.js";
interface Props {
container: HTMLElement | undefined;
label?: string;
}
let { container, label = "output" }: Props = $props();
interface Mark {
at: number;
severity: "error" | "warning";
text: string;
top: number;
}
let marks = $state<Mark[]>([]);
function measure() {
if (!container) {
marks = [];
return;
}
const height = container.scrollHeight;
if (height <= 0) {
marks = [];
return;
}
const rows = container.querySelectorAll<HTMLElement>("[data-severity]");
const found: Mark[] = [];
for (const row of rows) {
const severity = row.dataset.severity;
if (severity !== "error" && severity !== "warning") continue;
const top = row.offsetTop;
found.push({
at: Math.min(1, Math.max(0, top / height)),
severity,
text: (row.textContent ?? "").trim().slice(0, 160),
top,
});
}
marks = found;
}
onMount(() => {
let queued = 0;
const soon = () => {
if (queued) return;
queued = requestAnimationFrame(() => {
queued = 0;
measure();
});
};
const mutations = new MutationObserver(soon);
const resizes = new ResizeObserver(soon);
let watched: HTMLElement | undefined;
const attach = () => {
if (watched === container) return;
mutations.disconnect();
resizes.disconnect();
watched = container;
if (!container) {
marks = [];
return;
}
mutations.observe(container, { childList: true, subtree: true, characterData: true });
resizes.observe(container);
soon();
};
const poll = setInterval(attach, 250);
attach();
return () => {
clearInterval(poll);
mutations.disconnect();
resizes.disconnect();
if (queued) cancelAnimationFrame(queued);
};
});
function jump(mark: Mark) {
if (!container) return;
const middle = mark.top - container.clientHeight / 2;
container.scrollTo({ top: Math.max(0, middle), behavior: "smooth" });
}
const errors = $derived(marks.filter((mark) => mark.severity === "error").length);
const warnings = $derived(marks.filter((mark) => mark.severity === "warning").length);
const drawn = $derived(paintOrder(marks));
</script>
{#if marks.length > 0}
<div
class="scroll-marks"
role="navigation"
aria-label="{errors} error{errors === 1 ? '' : 's'} and {warnings} warning{warnings === 1
? ''
: 's'} in this {label}"
>
{#each drawn as mark, index (index)}
<button
class="scroll-mark scroll-mark--{mark.severity}"
style:top="{mark.at * 100}%"
title="{mark.severity === 'error' ? 'Error' : 'Warning'}: {mark.text}"
aria-label="Go to {mark.severity}: {mark.text}"
onclick={() => jump(mark)}
></button>
{/each}
</div>
{/if}
<style>
.scroll-marks {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: var(--wb-control-size-10);
pointer-events: none;
z-index: 2;
}
.scroll-mark {
position: absolute;
right: 0;
width: var(--wb-control-size-10);
height: var(--wb-control-size-8);
margin-top: var(--wb-spacing-neg-4);
padding: 0;
border: none;
background: none;
cursor: pointer;
pointer-events: auto;
}
.scroll-mark::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: var(--wb-spacing-4);
height: 1px;
background: currentColor;
}
.scroll-mark--error {
color: var(--wb-log-failed);
z-index: 2;
}
.scroll-mark--warning {
color: var(--wb-log-warn);
z-index: 1;
}
.scroll-mark:hover::after {
left: var(--wb-spacing-neg-4);
height: var(--wb-control-size-3);
top: var(--wb-spacing-3);
}
.scroll-mark:focus-visible {
outline: 1px solid var(--vscode-focusBorder, #0e70c0);
outline-offset: -1px;
}
</style>