mirror of
https://github.com/HananoshikaYomaru/openfreebooks.git
synced 2026-11-12 20:45:52 +00:00
Update TypeScript configuration to exclude test files, enhance CatalogMermaidView component with improved SVG handling and zoom functionality, and modify catalog-to-mermaid logic to include rootNodeId in diagram output. Adjust styles for z-index in catalog components for better layering.
This commit is contained in:
parent
f6150faaa3
commit
c66f8850a9
5 changed files with 389 additions and 17 deletions
|
|
@ -7,10 +7,13 @@ type CatalogMermaidViewProps = {
|
|||
};
|
||||
|
||||
type MermaidModule = typeof import("mermaid");
|
||||
type SvgPanZoomModule = typeof import("svg-pan-zoom")["default"];
|
||||
type SvgPanZoomModule = typeof import("svg-pan-zoom");
|
||||
const PAN_ZOOM_MIN = 0.3;
|
||||
const PAN_ZOOM_MAX = 8;
|
||||
|
||||
export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
||||
let container!: HTMLDivElement;
|
||||
let renderedSvg: SVGSVGElement | undefined;
|
||||
|
||||
let panZoom: SvgPanZoom.Instance | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
|
|
@ -27,15 +30,43 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
|
||||
const ensureModules = async () => {
|
||||
if (!mermaidModule || !svgPanZoomModule) {
|
||||
const [{ default: mermaid }, { default: svgPanZoom }] = await Promise.all([
|
||||
const [mermaidImport, svgPanZoomImport] = await Promise.all([
|
||||
import("mermaid"),
|
||||
import("svg-pan-zoom"),
|
||||
]);
|
||||
const svgPanZoomFromDefault = (
|
||||
svgPanZoomImport as unknown as {
|
||||
default?: SvgPanZoomModule;
|
||||
}
|
||||
).default;
|
||||
const svgPanZoom = svgPanZoomFromDefault ?? (svgPanZoomImport as unknown as SvgPanZoomModule);
|
||||
const mermaid = mermaidImport.default;
|
||||
mermaidModule = mermaid;
|
||||
svgPanZoomModule = svgPanZoom;
|
||||
}
|
||||
};
|
||||
|
||||
const fitWholeBoard = () => {
|
||||
if (!panZoom) return;
|
||||
panZoom.updateBBox();
|
||||
panZoom.resize();
|
||||
panZoom.fit();
|
||||
panZoom.contain();
|
||||
panZoom.center();
|
||||
};
|
||||
|
||||
const waitForContainerLayout = (el: HTMLElement): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (el.clientWidth > 1 && el.clientHeight > 1) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
const renderDiagram = async () => {
|
||||
const source = props.diagram.source;
|
||||
const theme = siteCanvasTheme();
|
||||
|
|
@ -45,6 +76,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
|
||||
const generation = ++renderGeneration;
|
||||
disposePanZoom();
|
||||
renderedSvg = undefined;
|
||||
|
||||
await ensureModules();
|
||||
if (generation !== renderGeneration || !mermaidModule || !svgPanZoomModule) return;
|
||||
|
|
@ -70,7 +102,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
container.innerHTML = svg;
|
||||
bindFunctions?.(container);
|
||||
|
||||
const renderedSvg = container.querySelector<SVGSVGElement>("svg");
|
||||
renderedSvg = container.querySelector<SVGSVGElement>("svg") ?? undefined;
|
||||
if (!renderedSvg) return;
|
||||
renderedSvg.classList.add("catalog-mermaid-view__svg");
|
||||
renderedSvg.removeAttribute("style");
|
||||
|
|
@ -83,15 +115,21 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
controlIconsEnabled: false,
|
||||
fit: true,
|
||||
center: true,
|
||||
minZoom: 0.3,
|
||||
maxZoom: 8,
|
||||
minZoom: PAN_ZOOM_MIN,
|
||||
maxZoom: PAN_ZOOM_MAX,
|
||||
dblClickZoomEnabled: true,
|
||||
mouseWheelZoomEnabled: true,
|
||||
});
|
||||
|
||||
panZoom.resize();
|
||||
panZoom.fit();
|
||||
panZoom.center();
|
||||
await waitForContainerLayout(container);
|
||||
if (document.fonts) {
|
||||
await document.fonts.ready;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
||||
});
|
||||
if (generation !== renderGeneration) return;
|
||||
fitWholeBoard();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
|
|
@ -102,9 +140,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
});
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
panZoom?.resize();
|
||||
panZoom?.fit();
|
||||
panZoom?.center();
|
||||
fitWholeBoard();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
|
|
@ -114,6 +150,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
resizeObserver?.disconnect();
|
||||
resizeObserver = undefined;
|
||||
disposePanZoom();
|
||||
renderedSvg = undefined;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -125,8 +162,7 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
const resetView = () => {
|
||||
if (!panZoom) return;
|
||||
panZoom.reset();
|
||||
panZoom.fit();
|
||||
panZoom.center();
|
||||
fitWholeBoard();
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
|
|
@ -137,6 +173,297 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
panZoom?.zoomOut();
|
||||
};
|
||||
|
||||
const resolveRootNode = (): SVGGElement | null => {
|
||||
if (!renderedSvg || !props.diagram.rootNodeId) return null;
|
||||
const escapedRootNodeId =
|
||||
globalThis.CSS && typeof globalThis.CSS.escape === "function"
|
||||
? globalThis.CSS.escape(props.diagram.rootNodeId)
|
||||
: props.diagram.rootNodeId.replaceAll(/["\\]/g, "\\$&");
|
||||
return (
|
||||
renderedSvg.querySelector<SVGGElement>(`g.node[data-id="${escapedRootNodeId}"]`) ??
|
||||
renderedSvg.querySelector<SVGGElement>(`g.node#${escapedRootNodeId}`) ??
|
||||
renderedSvg.querySelector<SVGGElement>(`g[id*="${escapedRootNodeId}"]`)
|
||||
);
|
||||
};
|
||||
|
||||
const zoomToRoot = () => {
|
||||
if (!panZoom || !renderedSvg || !props.diagram.rootNodeId) {
|
||||
console.log("[catalog-mermaid] zoomToRoot fallback", {
|
||||
hasPanZoom: Boolean(panZoom),
|
||||
hasSvg: Boolean(renderedSvg),
|
||||
rootNodeId: props.diagram.rootNodeId,
|
||||
reason: "missing prerequisites",
|
||||
});
|
||||
resetView();
|
||||
return;
|
||||
}
|
||||
|
||||
const rootNode = resolveRootNode();
|
||||
if (!rootNode) {
|
||||
console.log("[catalog-mermaid] zoomToRoot fallback", {
|
||||
rootNodeId: props.diagram.rootNodeId,
|
||||
reason: "root node not found in SVG",
|
||||
});
|
||||
resetView();
|
||||
return;
|
||||
}
|
||||
|
||||
panZoom.updateBBox();
|
||||
panZoom.resize();
|
||||
|
||||
const rootBounds = rootNode.getBBox();
|
||||
const sizes = panZoom.getSizes();
|
||||
const finiteOr = (value: number, fallback: number): number =>
|
||||
Number.isFinite(value) ? value : fallback;
|
||||
const viewportWidth = finiteOr(sizes.width, container.clientWidth);
|
||||
const viewportHeight = finiteOr(sizes.height, container.clientHeight);
|
||||
const minZoom = PAN_ZOOM_MIN;
|
||||
const maxZoom = Math.max(PAN_ZOOM_MAX, minZoom);
|
||||
const currentZoom = finiteOr(panZoom.getZoom(), finiteOr(sizes.realZoom, 1));
|
||||
const rootWidth = Math.max(finiteOr(rootBounds.width, 0), 1);
|
||||
const rootHeight = Math.max(finiteOr(rootBounds.height, 0), 1);
|
||||
|
||||
// Treat viewport as a 5x5 grid and place root at top-middle cell center.
|
||||
const gridColumns = 5;
|
||||
const gridRows = 5;
|
||||
const cellWidth = viewportWidth / gridColumns;
|
||||
const cellHeight = viewportHeight / gridRows;
|
||||
const targetViewportX = cellWidth * 2.5;
|
||||
const targetViewportY = cellHeight * 0.5;
|
||||
|
||||
// Fit root node within a single grid cell with some breathing room.
|
||||
const cellFitPadding = 0.8;
|
||||
const zoomToFitCellX = (cellWidth * cellFitPadding) / rootWidth;
|
||||
const zoomToFitCellY = (cellHeight * cellFitPadding) / rootHeight;
|
||||
const rootRectBeforeZoom = rootNode.getBoundingClientRect();
|
||||
const currentRootScreenHeight = Math.max(finiteOr(rootRectBeforeZoom.height, 0), 1);
|
||||
const targetRootScreenHeight = viewportHeight / 4;
|
||||
const magnification = targetRootScreenHeight / currentRootScreenHeight;
|
||||
const rawTargetZoom = currentZoom * magnification;
|
||||
const rootZoomFloor = Math.min(maxZoom, Math.max(minZoom, 1));
|
||||
const targetZoom = Math.min(maxZoom, Math.max(rootZoomFloor, rawTargetZoom));
|
||||
|
||||
if (!Number.isFinite(targetZoom) || targetZoom <= 0) {
|
||||
console.log("[catalog-mermaid] zoomToRoot fallback", {
|
||||
rootNodeId: props.diagram.rootNodeId,
|
||||
reason: "non-finite targetZoom",
|
||||
targetZoom,
|
||||
});
|
||||
resetView();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[catalog-mermaid] zoomToRoot", {
|
||||
rootNodeId: props.diagram.rootNodeId,
|
||||
rootNodeDomId: rootNode.id,
|
||||
rootNodeDataId: rootNode.getAttribute("data-id"),
|
||||
rootBounds: {
|
||||
x: rootBounds.x,
|
||||
y: rootBounds.y,
|
||||
width: rootBounds.width,
|
||||
height: rootBounds.height,
|
||||
},
|
||||
viewport: {
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
currentZoom,
|
||||
},
|
||||
grid: {
|
||||
columns: gridColumns,
|
||||
rows: gridRows,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
targetViewportX,
|
||||
targetViewportY,
|
||||
},
|
||||
zoom: {
|
||||
zoomToFitCellX,
|
||||
zoomToFitCellY,
|
||||
currentRootScreenHeight,
|
||||
targetRootScreenHeight,
|
||||
magnification,
|
||||
rawTargetZoom,
|
||||
rootZoomFloor,
|
||||
zoomMode: "root-height-quarter-viewport",
|
||||
targetZoom,
|
||||
},
|
||||
targetViewport: {
|
||||
x: targetViewportX,
|
||||
y: targetViewportY,
|
||||
},
|
||||
});
|
||||
|
||||
panZoom.zoom(targetZoom);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!panZoom) return;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rootRect = rootNode.getBoundingClientRect();
|
||||
const rootCenterScreenX = rootRect.left + rootRect.width / 2;
|
||||
const rootCenterScreenY = rootRect.top + rootRect.height / 2;
|
||||
const targetScreenX = containerRect.left + targetViewportX;
|
||||
const targetScreenY = containerRect.top + targetViewportY;
|
||||
const deltaX = targetScreenX - rootCenterScreenX;
|
||||
const deltaY = targetScreenY - rootCenterScreenY;
|
||||
|
||||
if (!Number.isFinite(deltaX) || !Number.isFinite(deltaY)) {
|
||||
console.log("[catalog-mermaid] zoomToRoot fallback", {
|
||||
rootNodeId: props.diagram.rootNodeId,
|
||||
reason: "non-finite delta pan",
|
||||
deltaX,
|
||||
deltaY,
|
||||
});
|
||||
resetView();
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPan = panZoom.getPan();
|
||||
const nextPan = {
|
||||
x: currentPan.x + deltaX,
|
||||
y: currentPan.y + deltaY,
|
||||
};
|
||||
if (!Number.isFinite(nextPan.x) || !Number.isFinite(nextPan.y)) {
|
||||
console.log("[catalog-mermaid] zoomToRoot fallback", {
|
||||
rootNodeId: props.diagram.rootNodeId,
|
||||
reason: "non-finite next pan",
|
||||
nextPan,
|
||||
});
|
||||
resetView();
|
||||
return;
|
||||
}
|
||||
|
||||
panZoom.pan(nextPan);
|
||||
console.log("[catalog-mermaid] zoomToRoot applied", {
|
||||
targetZoom,
|
||||
currentPan,
|
||||
nextPan,
|
||||
targetScreen: { x: targetScreenX, y: targetScreenY },
|
||||
rootCenterScreen: { x: rootCenterScreenX, y: rootCenterScreenY },
|
||||
delta: { x: deltaX, y: deltaY },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const snapshotView = () => {
|
||||
if (!panZoom || !renderedSvg) {
|
||||
console.log("[catalog-mermaid] snapshot fallback", {
|
||||
hasPanZoom: Boolean(panZoom),
|
||||
hasSvg: Boolean(renderedSvg),
|
||||
reason: "missing prerequisites",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
panZoom.updateBBox();
|
||||
panZoom.resize();
|
||||
|
||||
const finiteOr = (value: number, fallback: number): number =>
|
||||
Number.isFinite(value) ? value : fallback;
|
||||
const sizes = panZoom.getSizes();
|
||||
const viewportWidth = finiteOr(sizes.width, container.clientWidth);
|
||||
const viewportHeight = finiteOr(sizes.height, container.clientHeight);
|
||||
const minZoom = PAN_ZOOM_MIN;
|
||||
const maxZoom = Math.max(PAN_ZOOM_MAX, minZoom);
|
||||
const currentZoom = finiteOr(panZoom.getZoom(), finiteOr(sizes.realZoom, 1));
|
||||
const currentPan = panZoom.getPan();
|
||||
const rootNode = resolveRootNode();
|
||||
const rootBounds = rootNode?.getBBox();
|
||||
|
||||
let desired: Record<string, unknown> | null = null;
|
||||
if (rootNode && rootBounds) {
|
||||
const rootWidth = Math.max(finiteOr(rootBounds.width, 0), 1);
|
||||
const rootHeight = Math.max(finiteOr(rootBounds.height, 0), 1);
|
||||
const cellWidth = viewportWidth / 5;
|
||||
const cellHeight = viewportHeight / 5;
|
||||
const targetViewportX = cellWidth * 2.5;
|
||||
const targetViewportY = cellHeight * 0.5;
|
||||
const zoomToFitCellX = (cellWidth * 0.8) / rootWidth;
|
||||
const zoomToFitCellY = (cellHeight * 0.8) / rootHeight;
|
||||
const currentRootScreenHeight = Math.max(finiteOr(rootNode.getBoundingClientRect().height, 0), 1);
|
||||
const targetRootScreenHeight = viewportHeight / 4;
|
||||
const magnification = targetRootScreenHeight / currentRootScreenHeight;
|
||||
const rawTargetZoom = currentZoom * magnification;
|
||||
const rootZoomFloor = Math.min(maxZoom, Math.max(minZoom, 1));
|
||||
const targetZoom = Math.min(maxZoom, Math.max(rootZoomFloor, rawTargetZoom));
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const rootRect = rootNode.getBoundingClientRect();
|
||||
const rootCenterScreenX = rootRect.left + rootRect.width / 2;
|
||||
const rootCenterScreenY = rootRect.top + rootRect.height / 2;
|
||||
const targetScreenX = containerRect.left + targetViewportX;
|
||||
const targetScreenY = containerRect.top + targetViewportY;
|
||||
const deltaX = targetScreenX - rootCenterScreenX;
|
||||
const deltaY = targetScreenY - rootCenterScreenY;
|
||||
desired = {
|
||||
targetZoom,
|
||||
targetPanFromCurrent: {
|
||||
x: currentPan.x + deltaX,
|
||||
y: currentPan.y + deltaY,
|
||||
},
|
||||
targetScreen: {
|
||||
x: targetScreenX,
|
||||
y: targetScreenY,
|
||||
},
|
||||
rootCenterScreen: {
|
||||
x: rootCenterScreenX,
|
||||
y: rootCenterScreenY,
|
||||
},
|
||||
deltaPan: {
|
||||
x: deltaX,
|
||||
y: deltaY,
|
||||
},
|
||||
currentRootScreenHeight,
|
||||
targetRootScreenHeight,
|
||||
magnification,
|
||||
rawTargetZoom,
|
||||
rootZoomFloor,
|
||||
zoomToFitCellX,
|
||||
zoomToFitCellY,
|
||||
zoomMode: "root-height-quarter-viewport",
|
||||
grid: {
|
||||
columns: 5,
|
||||
rows: 5,
|
||||
targetViewportX,
|
||||
targetViewportY,
|
||||
cellWidth,
|
||||
cellHeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.log("[catalog-mermaid] snapshot", {
|
||||
rootNodeId: props.diagram.rootNodeId,
|
||||
rootNodeDomId: rootNode?.id ?? null,
|
||||
rootNodeDataId: rootNode?.getAttribute("data-id") ?? null,
|
||||
rootBounds: rootBounds
|
||||
? {
|
||||
x: rootBounds.x,
|
||||
y: rootBounds.y,
|
||||
width: rootBounds.width,
|
||||
height: rootBounds.height,
|
||||
}
|
||||
: null,
|
||||
actual: {
|
||||
viewport: {
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
},
|
||||
zoom: currentZoom,
|
||||
pan: currentPan,
|
||||
},
|
||||
desired,
|
||||
svg: {
|
||||
viewBox: renderedSvg.getAttribute("viewBox"),
|
||||
width: renderedSvg.getAttribute("width"),
|
||||
height: renderedSvg.getAttribute("height"),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="catalog-mermaid-stage">
|
||||
<div
|
||||
|
|
@ -155,6 +482,13 @@ export function CatalogMermaidView(props: CatalogMermaidViewProps) {
|
|||
<button type="button" class="catalog-mermaid-controls__btn" onClick={resetView}>
|
||||
Reset view
|
||||
</button>
|
||||
<button type="button" class="catalog-mermaid-controls__btn" onClick={zoomToRoot}>
|
||||
Zoom to root
|
||||
</button>
|
||||
{/* hidden for now, as we don't have a use case for it yet */}
|
||||
<button type="button" class="catalog-mermaid-controls__btn" onClick={snapshotView} style="display: none;">
|
||||
Snapshot
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ describe("subjectToMermaid", () => {
|
|||
const diagram = subjectToMermaid(subject, () => true, "math");
|
||||
expect(diagram).not.toBeNull();
|
||||
const source = diagram!.source;
|
||||
expect(diagram!.rootNodeId).toBe("chapter_a");
|
||||
|
||||
expect(source).toContain("flowchart TB");
|
||||
expect(source.match(/chapter_a --> chapter_c/g)?.length).toBe(1);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { curriculumBadgeClass, tierBadgeLabel } from "./catalog-badge";
|
|||
|
||||
export type MermaidCatalogDiagram = {
|
||||
source: string;
|
||||
rootNodeId: string | null;
|
||||
};
|
||||
|
||||
function collectVisibleChapters(
|
||||
|
|
@ -61,6 +62,37 @@ function classListNodeIds(
|
|||
return ids;
|
||||
}
|
||||
|
||||
function selectRootSlug(
|
||||
subject: CatalogSubject,
|
||||
visible: Map<string, CatalogChapter>,
|
||||
edges: CatalogGraphEdge[]
|
||||
): string | null {
|
||||
const indegree = new Map<string, number>();
|
||||
for (const slug of visible.keys()) {
|
||||
indegree.set(slug, 0);
|
||||
}
|
||||
for (const edge of edges) {
|
||||
indegree.set(edge.to, (indegree.get(edge.to) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const rootCandidates = new Set<string>();
|
||||
for (const [slug, degree] of indegree) {
|
||||
if (degree === 0) {
|
||||
rootCandidates.add(slug);
|
||||
}
|
||||
}
|
||||
|
||||
for (const strand of subject.strands) {
|
||||
for (const chapter of strand.chapters) {
|
||||
if (rootCandidates.has(chapter.slug)) {
|
||||
return chapter.slug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visible.keys().next().value ?? null;
|
||||
}
|
||||
|
||||
function chapterNodeLabel(chapter: CatalogChapter): string {
|
||||
const title = `<h3 class='catalog-mermaid-node__title'>${escapeLabel(chapter.title)}</h3>`;
|
||||
const description = chapter.description
|
||||
|
|
@ -93,6 +125,7 @@ export function subjectToMermaid(
|
|||
if (visible.size === 0) return null;
|
||||
|
||||
const edges = buildEdgeList(subject, visible);
|
||||
const rootSlug = selectRootSlug(subject, visible, edges);
|
||||
const lines: string[] = ["flowchart TB"];
|
||||
|
||||
for (const strand of subject.strands) {
|
||||
|
|
@ -132,5 +165,8 @@ export function subjectToMermaid(
|
|||
lines.push(" classDef liveNode stroke-width:2px;");
|
||||
lines.push(" classDef plannedNode stroke-dasharray: 5 3;");
|
||||
|
||||
return { source: lines.join("\n") };
|
||||
return {
|
||||
source: lines.join("\n"),
|
||||
rootNodeId: rootSlug ? nodeId(rootSlug) : null,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,7 +159,7 @@
|
|||
background: var(--card-bg);
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.catalog-header__head {
|
||||
|
|
@ -370,7 +370,7 @@
|
|||
position: absolute;
|
||||
right: 20px;
|
||||
top: 20px;
|
||||
z-index: 4;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@
|
|||
"noEmit": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["frontend/src"]
|
||||
"include": ["frontend/src"],
|
||||
"exclude": ["frontend/src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue