Files
kandev/apps/web/components/editors/monaco/use-monaco-walkthrough-range.ts
T
2026-07-28 16:38:56 +01:00

254 lines
8.4 KiB
TypeScript

import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type RefObject,
} from "react";
import type { editor as monacoEditor } from "monaco-editor";
import { useAppStore } from "@/components/state-provider";
import {
clearWalkthroughEditorAnchor,
isWalkthroughAnchorTargetVisible,
setWalkthroughEditorAnchor,
type WalkthroughViewportRect,
} from "@/lib/walkthrough-editor-anchor";
import {
getWalkthroughEditorRange,
type WalkthroughEditorRange,
type WalkthroughRangeBox,
} from "@/lib/walkthrough-editor-range";
import { useIsWalkthroughOpenForTask } from "@/lib/walkthrough-open-state";
export { getWalkthroughEditorRange } from "@/lib/walkthrough-editor-range";
type UseMonacoWalkthroughRangeOpts = {
editor: monacoEditor.IStandaloneCodeEditor | null;
editorAreaRef: RefObject<HTMLDivElement | null>;
path: string;
repo?: string;
};
export function buildWalkthroughRangeDecorations(
range: WalkthroughEditorRange | null,
): monacoEditor.IModelDeltaDecoration[] {
if (!range) return [];
const decorations: monacoEditor.IModelDeltaDecoration[] = [];
for (let line = range.startLine; line <= range.endLine; line++) {
decorations.push({
range: { startLineNumber: line, startColumn: 1, endLineNumber: line, endColumn: 1 },
options: {
isWholeLine: true,
className: "monaco-walkthrough-line",
lineNumberClassName: "monaco-walkthrough-line-number",
linesDecorationsClassName:
line === range.startLine ? "monaco-walkthrough-range-start" : "monaco-walkthrough-range",
},
});
}
return decorations;
}
export function clampWalkthroughRangeToLineCount(
range: WalkthroughEditorRange,
lineCount: number | null | undefined,
): WalkthroughEditorRange {
if (!lineCount || lineCount < 1) return range;
const startLine = Math.min(Math.max(range.startLine, 1), lineCount);
const endLine = Math.min(Math.max(range.endLine, startLine), lineCount);
return { startLine, endLine };
}
function rectFromViewport(left: number, top: number, width: number, height: number) {
return { left, top, width, height, right: left + width, bottom: top + height };
}
function measureRangeBox(
editor: monacoEditor.IStandaloneCodeEditor,
area: HTMLElement,
range: WalkthroughEditorRange,
): { box: WalkthroughRangeBox; viewportRect: WalkthroughViewportRect } | null {
const editorDom = editor.getDomNode();
if (!editorDom) return null;
const start = editor.getScrolledVisiblePosition({ lineNumber: range.startLine, column: 1 });
if (!start) return null;
const end = editor.getScrolledVisiblePosition({ lineNumber: range.endLine, column: 1 }) ?? start;
const editorRect = editorDom.getBoundingClientRect();
const areaRect = area.getBoundingClientRect();
const lineHeight = start.height || end.height || 18;
const top = editorRect.top - areaRect.top + start.top;
const height = Math.max(lineHeight, end.top - start.top + (end.height || lineHeight));
const left = Math.max(48, start.left - 8);
const width = Math.max(160, editorRect.width - left - 16);
return {
box: { ...range, top, left, width, height },
viewportRect: rectFromViewport(
editorRect.left + left,
editorRect.top + start.top,
width,
height,
),
};
}
function useActiveWalkthroughStep() {
const taskId = useAppStore((s) => s.tasks.activeTaskId);
const isOpen = useIsWalkthroughOpenForTask(taskId);
const stepIndex = useAppStore((s) =>
taskId ? (s.walkthroughs.activeStepByTaskId[taskId] ?? 0) : 0,
);
const step = useAppStore((s) =>
taskId ? (s.walkthroughs.byTaskId[taskId]?.steps[stepIndex] ?? null) : null,
);
return { taskId, stepIndex, step: isOpen ? step : null };
}
function useMonacoModelSnapshot(editor: monacoEditor.IStandaloneCodeEditor | null): string {
const subscribe = useCallback(
(onStoreChange: () => void) => {
if (!editor) return () => {};
const modelSubscription = editor.onDidChangeModel(onStoreChange);
const contentSubscription = editor.onDidChangeModelContent(onStoreChange);
return () => {
modelSubscription.dispose();
contentSubscription.dispose();
};
},
[editor],
);
const getSnapshot = useCallback(() => {
const model = editor?.getModel();
return model ? `${model.id}:${model.getLineCount()}` : "";
}, [editor]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
function useMonacoWalkthroughDecorations(
editor: monacoEditor.IStandaloneCodeEditor | null,
range: WalkthroughEditorRange | null,
anchorKey: string,
modelSnapshot: string,
) {
const decorationsRef = useRef<monacoEditor.IEditorDecorationsCollection | null>(null);
const revealedRef = useRef<{
editor: monacoEditor.IStandaloneCodeEditor;
model: monacoEditor.ITextModel | null;
anchorKey: string;
startLine: number;
endLine: number;
} | null>(null);
useEffect(() => {
if (!editor) return;
decorationsRef.current = editor.createDecorationsCollection([]);
return () => {
decorationsRef.current?.set([]);
decorationsRef.current = null;
};
}, [editor]);
useEffect(() => {
decorationsRef.current?.set(buildWalkthroughRangeDecorations(range));
if (!editor || !range) {
revealedRef.current = null;
return;
}
const next = { editor, model: editor.getModel(), anchorKey, ...range };
const previous = revealedRef.current;
if (
previous?.editor === next.editor &&
previous.model === next.model &&
previous.anchorKey === next.anchorKey &&
previous.startLine === next.startLine &&
previous.endLine === next.endLine
) {
return;
}
revealedRef.current = next;
editor.revealLinesInCenter(range.startLine, range.endLine);
}, [anchorKey, editor, modelSnapshot, range]);
}
export function useMonacoWalkthroughRange({
editor,
editorAreaRef,
path,
repo,
}: UseMonacoWalkthroughRangeOpts): WalkthroughRangeBox | null {
const [box, setBox] = useState<WalkthroughRangeBox | null>(null);
const { taskId, stepIndex, step } = useActiveWalkthroughStep();
const modelSnapshot = useMonacoModelSnapshot(editor);
const range = useMemo(
() => getWalkthroughEditorRange({ path, repository_name: repo }, step),
[path, repo, step],
);
const clampedRange = useMemo(
() =>
range ? clampWalkthroughRangeToLineCount(range, editor?.getModel()?.getLineCount()) : null,
[editor, modelSnapshot, range],
);
const anchorKey = taskId ? `${taskId}:${stepIndex}:${repo ?? ""}:${path}` : "";
useMonacoWalkthroughDecorations(editor, clampedRange, anchorKey, modelSnapshot);
useEffect(() => {
const area = editorAreaRef.current;
if (!editor || !area || !clampedRange || !taskId || !step) {
setBox(null);
if (anchorKey) clearWalkthroughEditorAnchor(anchorKey);
return;
}
let frame: number | null = null;
const update = () => {
if (frame !== null) cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const measured = measureRangeBox(editor, area, clampedRange);
if (!measured) {
setBox(null);
clearWalkthroughEditorAnchor(anchorKey);
return;
}
const editorDom = editor.getDomNode();
setBox(measured.box);
if (!isWalkthroughAnchorTargetVisible(editorDom, measured.viewportRect)) {
clearWalkthroughEditorAnchor(anchorKey);
return;
}
setWalkthroughEditorAnchor({
key: anchorKey,
taskId,
stepIndex,
file: step.file,
repo: step.repo,
line: clampedRange.startLine,
lineEnd: clampedRange.endLine,
rect: measured.viewportRect,
container: editorDom ?? undefined,
});
});
};
update();
const scrollSub = editor.onDidScrollChange(update);
const layoutSub = editor.onDidLayoutChange(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(area);
const visibilityInterval = window.setInterval(update, 150);
window.addEventListener("resize", update);
return () => {
if (frame !== null) cancelAnimationFrame(frame);
scrollSub.dispose();
layoutSub.dispose();
resizeObserver.disconnect();
window.clearInterval(visibilityInterval);
window.removeEventListener("resize", update);
clearWalkthroughEditorAnchor(anchorKey);
};
}, [anchorKey, clampedRange, editor, editorAreaRef, path, step, stepIndex, taskId]);
return box;
}