Files
kandev/apps/web/lib/markdown/source-line-ranges.ts
T
José Almeida 12ed7123ab fix: support markdown preview comments (#1648)
* fix: support markdown preview comments

* fix: address markdown preview review feedback

* fix: address markdown preview comment scope

* fix: address markdown preview follow-up feedback

* fix: preserve markdown preview run action in file tabs

* fix: ignore svg clicks in markdown preview controls

* fix: ignore svg markdown preview selection targets
2026-07-10 14:03:32 +01:00

162 lines
4.8 KiB
TypeScript

export type SourceLineRange = {
startLine: number;
endLine: number;
};
export type MarkdownPreviewSelection = SourceLineRange & {
selectedText: string;
position: { x: number; y: number };
};
export const SOURCE_START_ATTR = "data-md-source-start";
export const SOURCE_END_ATTR = "data-md-source-end";
const MAX_FALLBACK_SELECTION_SPAN = 30;
function readPositiveInt(value: string | null): number | null {
if (!value) return null;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
export function readSourceLineRange(element: Element): SourceLineRange | null {
const start = readPositiveInt(element.getAttribute(SOURCE_START_ATTR));
const end = readPositiveInt(element.getAttribute(SOURCE_END_ATTR)) ?? start;
if (!start || !end) return null;
return {
startLine: Math.min(start, end),
endLine: Math.max(start, end),
};
}
export function findNearestSourceElement(root: HTMLElement, node: Node | null): HTMLElement | null {
if (!node || !root.contains(node)) return null;
let element =
node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: ((node.parentElement ?? null) as HTMLElement | null);
while (element && root.contains(element)) {
if (readSourceLineRange(element)) return element;
if (element === root) break;
element = element.parentElement;
}
return null;
}
function normalizeSearchText(text: string): string {
return text.replace(/\s+/g, " ").trim().toLowerCase();
}
function normalizeMarkdownSourceWindow(text: string): string {
return normalizeSearchText(
text
.replace(/^#{1,6}\s+/gm, "")
.replace(/^\s*[-*+]\s+/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
.replace(/^>\s?/gm, "")
.replace(/[`*_~[\]()]/g, ""),
);
}
export function findLineRangeForSelectedText(
content: string,
selectedText: string,
): SourceLineRange | null {
const needle = normalizeSearchText(selectedText);
if (!needle) return null;
const lines = content.replace(/\r\n?/g, "\n").split("\n");
for (let span = 1; span <= Math.min(lines.length, MAX_FALLBACK_SELECTION_SPAN); span++) {
for (let start = 0; start + span <= lines.length; start++) {
const end = start + span - 1;
const haystack = normalizeMarkdownSourceWindow(lines.slice(start, end + 1).join(" "));
if (!haystack) continue;
if (haystack === needle || haystack.includes(needle)) {
return { startLine: start + 1, endLine: end + 1 };
}
}
}
return null;
}
function selectionTouchesRoot(root: HTMLElement, range: Range): boolean {
return root.contains(range.startContainer) && root.contains(range.endContainer);
}
function selectionPosition(
range: Range,
fallbackElement: HTMLElement | null,
): { x: number; y: number } {
const rect = range.getBoundingClientRect?.();
if (rect && (rect.left || rect.top || rect.right || rect.bottom)) {
return { x: rect.right, y: rect.bottom };
}
const fallbackRect = fallbackElement?.getBoundingClientRect();
if (fallbackRect) return { x: fallbackRect.right, y: fallbackRect.bottom };
return { x: 0, y: 0 };
}
function mergeSourceRanges(
startRange: SourceLineRange | null,
endRange: SourceLineRange | null,
): SourceLineRange | null {
if (!startRange && !endRange) return null;
const startLine = Math.min(
startRange?.startLine ?? endRange!.startLine,
endRange?.startLine ?? startRange!.startLine,
);
const endLine = Math.max(
startRange?.endLine ?? endRange!.endLine,
endRange?.endLine ?? startRange!.endLine,
);
return { startLine, endLine };
}
function sourceRangeForSelection(
root: HTMLElement,
content: string,
selectedText: string,
range: Range,
): {
lineRange: SourceLineRange | null;
fallbackElement: HTMLElement | null;
} {
const startElement = findNearestSourceElement(root, range.startContainer);
const endElement = findNearestSourceElement(root, range.endContainer);
const startRange = startElement ? readSourceLineRange(startElement) : null;
const endRange = endElement ? readSourceLineRange(endElement) : null;
return {
lineRange:
mergeSourceRanges(startRange, endRange) ??
findLineRangeForSelectedText(content, selectedText),
fallbackElement: endElement ?? startElement,
};
}
export function resolveMarkdownDomSelection(
root: HTMLElement,
content: string,
selection: Selection,
): MarkdownPreviewSelection | null {
if (selection.rangeCount === 0) return null;
const selectedText = selection.toString().trim();
if (!selectedText) return null;
const range = selection.getRangeAt(0);
if (!selectionTouchesRoot(root, range)) return null;
const { lineRange, fallbackElement } = sourceRangeForSelection(
root,
content,
selectedText,
range,
);
if (!lineRange) return null;
return {
...lineRange,
selectedText,
position: selectionPosition(range, fallbackElement),
};
}