feat: add approvable interactive UML stages
This commit is contained in:
+266
-16
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./react.css";
|
||||
|
||||
@@ -15,6 +15,8 @@ type UmlStage = {
|
||||
label: string;
|
||||
description: string;
|
||||
code_ref?: string;
|
||||
progress_id?: string;
|
||||
svg_label?: string;
|
||||
};
|
||||
type InteractiveAsset = {
|
||||
kind: "allocator-memory-flow" | "uml-sequence";
|
||||
@@ -47,6 +49,7 @@ type Task = {
|
||||
title: string;
|
||||
uuid?: string;
|
||||
criterion: string;
|
||||
conclusion_html: string;
|
||||
prompt_html?: string;
|
||||
flow: FlowBlock[];
|
||||
};
|
||||
@@ -66,6 +69,7 @@ type Progress = {
|
||||
items: Record<string, { status: Status }>;
|
||||
summary: { total: number; counts: Record<Status, number> };
|
||||
};
|
||||
type SetProgressStatus = (id: string, status: Status) => Promise<void>;
|
||||
type CardModel = {
|
||||
schema: string;
|
||||
card: Record<string, string>;
|
||||
@@ -396,12 +400,20 @@ function InteractiveMemoryFlow({
|
||||
function InteractiveUmlSequence({
|
||||
config,
|
||||
asset,
|
||||
progress,
|
||||
onSetStatus,
|
||||
}: {
|
||||
config: InteractiveAsset;
|
||||
asset: Asset;
|
||||
progress: Progress | null;
|
||||
onSetStatus: SetProgressStatus;
|
||||
}) {
|
||||
const stages = config.stages ?? [];
|
||||
const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const [svgMarkup, setSvgMarkup] = useState("");
|
||||
const [svgFailed, setSvgFailed] = useState(false);
|
||||
const [approvalBusy, setApprovalBusy] = useState(false);
|
||||
const [stageIndex, setStageIndex] = useState(() =>
|
||||
Math.max(
|
||||
0,
|
||||
@@ -415,6 +427,156 @@ function InteractiveUmlSequence({
|
||||
() => localStorage.setItem(storageKey, String(stageIndex)),
|
||||
[stageIndex, storageKey],
|
||||
);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setSvgFailed(false);
|
||||
fetch(asset.href, { cache: "no-store" })
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
})
|
||||
.then((markup) => {
|
||||
const document = new DOMParser().parseFromString(
|
||||
markup,
|
||||
"image/svg+xml",
|
||||
);
|
||||
const svg = document.documentElement;
|
||||
if (svg.nodeName.toLowerCase() !== "svg" || document.querySelector("parsererror")) {
|
||||
throw new Error("Niepoprawny SVG");
|
||||
}
|
||||
document.querySelectorAll("script, foreignObject").forEach((node) => node.remove());
|
||||
svg.removeAttribute("width");
|
||||
svg.removeAttribute("height");
|
||||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||||
if (current) setSvgMarkup(svg.outerHTML);
|
||||
})
|
||||
.catch(() => {
|
||||
if (current) setSvgFailed(true);
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [asset.href]);
|
||||
useEffect(() => {
|
||||
const svg = canvasRef.current?.querySelector("svg");
|
||||
if (!svg) return;
|
||||
|
||||
svg.setAttribute("role", "img");
|
||||
svg.setAttribute("aria-label", asset.alt);
|
||||
svg
|
||||
.querySelectorAll<SVGRectElement>("[data-uml-stage]")
|
||||
.forEach((rect) => {
|
||||
rect.classList.remove(
|
||||
"uml-stage-region",
|
||||
"uml-stage-region-bg",
|
||||
"uml-stage-region-outline",
|
||||
"uml-stage-region-active",
|
||||
"uml-stage-region-completed",
|
||||
);
|
||||
rect.removeAttribute("data-uml-stage");
|
||||
rect.removeAttribute("data-uml-stage-label");
|
||||
rect.removeAttribute("role");
|
||||
rect.removeAttribute("tabindex");
|
||||
rect.removeAttribute("aria-label");
|
||||
});
|
||||
|
||||
const texts = Array.from(svg.querySelectorAll<SVGTextElement>("text"));
|
||||
const rects = Array.from(svg.querySelectorAll<SVGRectElement>("rect"));
|
||||
stages.forEach((item, index) => {
|
||||
if (!item.svg_label) return;
|
||||
const label = texts.find((text) =>
|
||||
(text.textContent ?? "").trim().startsWith(item.svg_label!),
|
||||
);
|
||||
const labelY = Number(label?.getAttribute("y"));
|
||||
if (!label || !Number.isFinite(labelY)) return;
|
||||
|
||||
const candidates = rects
|
||||
.map((rect) => {
|
||||
const y = Number(rect.getAttribute("y"));
|
||||
const height = Number(rect.getAttribute("height"));
|
||||
const width = Number(rect.getAttribute("width"));
|
||||
const style = rect.getAttribute("style") ?? "";
|
||||
const strokeWidth = Number(
|
||||
style.match(/stroke-width:\s*([\d.]+)/)?.[1] ??
|
||||
rect.getAttribute("stroke-width") ??
|
||||
0,
|
||||
);
|
||||
return { rect, y, height, width, strokeWidth, area: width * height };
|
||||
})
|
||||
.filter(
|
||||
({ y, height, width, strokeWidth }) =>
|
||||
Number.isFinite(y) &&
|
||||
Number.isFinite(height) &&
|
||||
Number.isFinite(width) &&
|
||||
strokeWidth >= 2 &&
|
||||
y <= labelY &&
|
||||
y + height >= labelY,
|
||||
)
|
||||
.sort((left, right) => left.area - right.area);
|
||||
const region = candidates[0];
|
||||
if (!region) return;
|
||||
|
||||
const sameRegion = candidates.filter(
|
||||
({ y, height, width }) =>
|
||||
Math.abs(y - region.y) < 0.1 &&
|
||||
Math.abs(height - region.height) < 0.1 &&
|
||||
Math.abs(width - region.width) < 0.1,
|
||||
);
|
||||
const isCompleted = Boolean(
|
||||
item.progress_id &&
|
||||
progress?.items?.[item.progress_id]?.status === "completed",
|
||||
);
|
||||
sameRegion.forEach(({ rect }, duplicateIndex) => {
|
||||
const isBackground = rect.getAttribute("fill") !== "none";
|
||||
rect.dataset.umlStage = String(index);
|
||||
rect.dataset.umlStageLabel = item.label;
|
||||
rect.classList.add(
|
||||
"uml-stage-region",
|
||||
isBackground ? "uml-stage-region-bg" : "uml-stage-region-outline",
|
||||
);
|
||||
rect.classList.toggle("uml-stage-region-active", index === stageIndex);
|
||||
rect.classList.toggle("uml-stage-region-completed", isCompleted);
|
||||
if (duplicateIndex === 0) {
|
||||
rect.setAttribute("role", "button");
|
||||
rect.setAttribute("tabindex", "0");
|
||||
rect.setAttribute("aria-label", `Pokaż etap ${item.label}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [asset.alt, progress, stageIndex, stages, svgMarkup]);
|
||||
|
||||
const selectStageAtPoint = (clientX: number, clientY: number) => {
|
||||
const svg = canvasRef.current?.querySelector("svg");
|
||||
if (!svg) return;
|
||||
const hit = Array.from(
|
||||
svg.querySelectorAll<SVGRectElement>(".uml-stage-region-bg[data-uml-stage]"),
|
||||
)
|
||||
.filter((rect) => {
|
||||
const bounds = rect.getBoundingClientRect();
|
||||
return (
|
||||
clientX >= bounds.left &&
|
||||
clientX <= bounds.right &&
|
||||
clientY >= bounds.top &&
|
||||
clientY <= bounds.bottom
|
||||
);
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const a = left.getBoundingClientRect();
|
||||
const b = right.getBoundingClientRect();
|
||||
return a.width * a.height - b.width * b.height;
|
||||
})[0];
|
||||
const next = Number(hit?.dataset.umlStage);
|
||||
if (Number.isInteger(next)) setStageIndex(next);
|
||||
};
|
||||
|
||||
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
const target = event.target as SVGRectElement;
|
||||
const next = Number(target.dataset?.umlStage);
|
||||
if (!Number.isInteger(next)) return;
|
||||
event.preventDefault();
|
||||
setStageIndex(next);
|
||||
};
|
||||
const stage = stages[stageIndex];
|
||||
return (
|
||||
<div className="uml-react">
|
||||
@@ -428,21 +590,45 @@ function InteractiveUmlSequence({
|
||||
<button
|
||||
key={item.id}
|
||||
aria-pressed={stageIndex === index}
|
||||
data-completed={
|
||||
item.progress_id &&
|
||||
progress?.items?.[item.progress_id]?.status === "completed"
|
||||
? "true"
|
||||
: "false"
|
||||
}
|
||||
onClick={() => setStageIndex(index)}
|
||||
>
|
||||
{item.progress_id &&
|
||||
progress?.items?.[item.progress_id]?.status === "completed" && (
|
||||
<span aria-hidden="true">✓ </span>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
<a
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="uml-canvas"
|
||||
href={asset.href}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
onClick={(event) => selectStageAtPoint(event.clientX, event.clientY)}
|
||||
onKeyDown={handleSvgKeyDown}
|
||||
>
|
||||
<img src={asset.href} alt={asset.alt} />
|
||||
</a>
|
||||
{svgMarkup ? (
|
||||
<div
|
||||
className="uml-svg-host"
|
||||
// The SVG is a generated, same-origin PlantUML asset; scripts and
|
||||
// foreignObject nodes are removed before it reaches the DOM.
|
||||
dangerouslySetInnerHTML={{ __html: svgMarkup }}
|
||||
/>
|
||||
) : (
|
||||
<img src={asset.href} alt={asset.alt} />
|
||||
)}
|
||||
{svgFailed && (
|
||||
<span className="uml-inline-warning">
|
||||
Widok statyczny — nie udało się wczytać interaktywnego SVG.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{stage && (
|
||||
<aside className="uml-stage">
|
||||
<strong>{stage.label}</strong>
|
||||
@@ -450,11 +636,53 @@ function InteractiveUmlSequence({
|
||||
{stage.code_ref && <code>{stage.code_ref}</code>}
|
||||
</aside>
|
||||
)}
|
||||
{stage?.progress_id && (
|
||||
<footer className="uml-approval">
|
||||
<span>
|
||||
{progress?.items?.[stage.progress_id]?.status === "completed"
|
||||
? "Ten stan jest zatwierdzony i zapisany w JSON-ie zajęć."
|
||||
: "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."}
|
||||
</span>
|
||||
<button
|
||||
disabled={!progress || approvalBusy}
|
||||
onClick={async () => {
|
||||
if (!stage.progress_id || !progress) return;
|
||||
const completed =
|
||||
progress.items?.[stage.progress_id]?.status === "completed";
|
||||
setApprovalBusy(true);
|
||||
try {
|
||||
await onSetStatus(
|
||||
stage.progress_id,
|
||||
completed ? "not_started" : "completed",
|
||||
);
|
||||
} finally {
|
||||
setApprovalBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{approvalBusy
|
||||
? "Zapisywanie…"
|
||||
: progress?.items?.[stage.progress_id]?.status === "completed"
|
||||
? "Cofnij zatwierdzenie"
|
||||
: "Zatwierdź stan"}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
|
||||
function AssetFigure({
|
||||
asset,
|
||||
number,
|
||||
progress,
|
||||
onSetStatus,
|
||||
}: {
|
||||
asset: Asset;
|
||||
number: number;
|
||||
progress: Progress | null;
|
||||
onSetStatus: SetProgressStatus;
|
||||
}) {
|
||||
const interactiveKind = asset.interactive?.kind;
|
||||
const isInteractive = Boolean(interactiveKind);
|
||||
return (
|
||||
@@ -463,7 +691,12 @@ function AssetFigure({ asset, number }: { asset: Asset; number: number }) {
|
||||
id={asset.label.replace(":", "-")}
|
||||
>
|
||||
{interactiveKind === "uml-sequence" ? (
|
||||
<InteractiveUmlSequence config={asset.interactive!} asset={asset} />
|
||||
<InteractiveUmlSequence
|
||||
config={asset.interactive!}
|
||||
asset={asset}
|
||||
progress={progress}
|
||||
onSetStatus={onSetStatus}
|
||||
/>
|
||||
) : interactiveKind === "allocator-memory-flow" ? (
|
||||
<>
|
||||
<InteractiveMemoryFlow
|
||||
@@ -548,11 +781,13 @@ function SectionPage({
|
||||
section,
|
||||
progress,
|
||||
onCycle,
|
||||
onSetStatus,
|
||||
figureOffset,
|
||||
}: {
|
||||
section: Section;
|
||||
progress: Progress | null;
|
||||
onCycle: (id: string, status: Status) => void;
|
||||
onSetStatus: SetProgressStatus;
|
||||
figureOffset: number;
|
||||
}) {
|
||||
return (
|
||||
@@ -579,8 +814,19 @@ function SectionPage({
|
||||
key={asset.label}
|
||||
asset={asset}
|
||||
number={figureOffset + index + 1}
|
||||
progress={progress}
|
||||
onSetStatus={onSetStatus}
|
||||
/>
|
||||
))}
|
||||
{section.tasks.map(
|
||||
(task) =>
|
||||
task.conclusion_html && (
|
||||
<aside className="task-conclusion" key={`${task.ref}-conclusion`}>
|
||||
<strong>WNIOSEK</strong>
|
||||
<Html html={task.conclusion_html} />
|
||||
</aside>
|
||||
),
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -655,17 +901,12 @@ function App({ model }: { model: CardModel }) {
|
||||
window.addEventListener("wheel", wheel, { passive: false });
|
||||
return () => window.removeEventListener("wheel", wheel);
|
||||
}, []);
|
||||
const cycle = async (id: string, current: Status) => {
|
||||
const next: Record<Status, Status> = {
|
||||
not_started: "discussed",
|
||||
discussed: "completed",
|
||||
completed: "not_started",
|
||||
};
|
||||
const setStatus: SetProgressStatus = async (id, status) => {
|
||||
try {
|
||||
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ status: next[current] }),
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
setProgress((await response.json()).progress);
|
||||
@@ -676,6 +917,14 @@ function App({ model }: { model: CardModel }) {
|
||||
);
|
||||
}
|
||||
};
|
||||
const cycle = (id: string, current: Status) => {
|
||||
const next: Record<Status, Status> = {
|
||||
not_started: "discussed",
|
||||
discussed: "completed",
|
||||
completed: "not_started",
|
||||
};
|
||||
void setStatus(id, next[current]);
|
||||
};
|
||||
let figureCount = 0;
|
||||
return (
|
||||
<>
|
||||
@@ -691,6 +940,7 @@ function App({ model }: { model: CardModel }) {
|
||||
section={section}
|
||||
progress={progress}
|
||||
onCycle={cycle}
|
||||
onSetStatus={setStatus}
|
||||
figureOffset={offset}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user