feat: add hierarchical lesson session controls

This commit is contained in:
user
2026-07-17 13:31:35 +02:00
parent dbe8479e98
commit 3432fca481
2 changed files with 418 additions and 158 deletions
+410 -146
View File
@@ -1,8 +1,47 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useCallback, useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import "./react.css"; import "./react.css";
type Status = "not_started" | "discussed" | "completed"; type Status = "pending" | "approved";
type NavigationLevel = "task" | "block" | "phase" | "step" | "snapshot";
type NavigationPosition = {
task_index: number;
block_index: number;
phase_index: number;
step_index: number;
global_step_index: number;
snapshot_index: number | null;
global_snapshot_index: number | null;
};
type ApiNavigation = {
revision: number;
actor?: string;
focus_level: NavigationLevel;
task: { id: string; label: string; index: number };
block: { id: string; label: string; index: number };
phase: { id: string; label: string; index: number };
step: { id: string; label: string; number: number; index: number; global_index: number };
snapshot_ref: string | null;
position: NavigationPosition;
sync_requested: boolean;
};
type ApiViewerState = {
revision: number;
actor?: string | null;
scale: number;
scroll: { x: number; y: number; page_index: number };
viewport: { width: number; height: number };
nvim: {
visible: boolean;
sync: boolean;
control: boolean;
expanded: boolean;
fit: boolean;
connection: string;
screen_cursor: { row: number; column: number } | null;
grid: { columns: number; rows: number } | null;
};
};
type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text"; type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text";
type InteractiveSymbol = { type InteractiveSymbol = {
id: string; id: string;
@@ -204,6 +243,8 @@ type NvimPanelSummary = {
syncReady: boolean; syncReady: boolean;
controlMode: boolean; controlMode: boolean;
controlEnabled: boolean; controlEnabled: boolean;
screenCursor?: { row: number; column: number };
grid?: { columns: number; rows: number };
}; };
const nvimStatusLabel: Record<NvimPanelSummary["state"], string> = { const nvimStatusLabel: Record<NvimPanelSummary["state"], string> = {
@@ -408,6 +449,7 @@ type ActiveNvimStep = {
block: { id?: string; label?: string } | null; block: { id?: string; label?: string } | null;
phase: { id: string; label: string }; phase: { id: string; label: string };
step: UmlSequenceStep; step: UmlSequenceStep;
focusLevel?: NavigationLevel;
activate?: boolean; activate?: boolean;
}; };
type NvimConnectionStatus = { type NvimConnectionStatus = {
@@ -901,6 +943,7 @@ function NeovimPanel({
const websocketRef = useRef<WebSocket | null>(null); const websocketRef = useRef<WebSocket | null>(null);
const redrawFrameRef = useRef(0); const redrawFrameRef = useRef(0);
const gridFlushRef = useRef(0); const gridFlushRef = useRef(0);
const gridReportTimerRef = useRef(0);
const gridRef = useRef<NvimGridModel>(createNvimGrid()); const gridRef = useRef<NvimGridModel>(createNvimGrid());
const targetEpochRef = useRef<string | null>(null); const targetEpochRef = useRef<string | null>(null);
const activeStepRef = useRef<ActiveNvimStep | null>(null); const activeStepRef = useRef<ActiveNvimStep | null>(null);
@@ -910,6 +953,7 @@ function NeovimPanel({
const resizeStartRef = useRef<{ y: number; heightVh: number } | null>(null); const resizeStartRef = useRef<{ y: number; heightVh: number } | null>(null);
const [activeStep, setActiveStep] = useState<ActiveNvimStep | null>(null); const [activeStep, setActiveStep] = useState<ActiveNvimStep | null>(null);
const [hasGrid, setHasGrid] = useState(false); const [hasGrid, setHasGrid] = useState(false);
const [gridRevision, setGridRevision] = useState(0);
const [pointerInside, setPointerInside] = useState(false); const [pointerInside, setPointerInside] = useState(false);
const [panelHeightVh, setPanelHeightVh] = useState(() => { const [panelHeightVh, setPanelHeightVh] = useState(() => {
const saved = Number(localStorage.getItem("stem-card-nvim-height-vh")); const saved = Number(localStorage.getItem("stem-card-nvim-height-vh"));
@@ -947,22 +991,6 @@ function NeovimPanel({
setActiveStep(detail); setActiveStep(detail);
presentedSnapshotRef.current = null; presentedSnapshotRef.current = null;
const snapshotRef = detail.step.snapshot_ref; const snapshotRef = detail.step.snapshot_ref;
void fetch("/api/navigation/current", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
task: detail.task,
block: detail.block,
phase: detail.phase,
step: {
id: detail.step.id,
number: detail.step.number,
label: detail.step.label,
},
snapshot_ref: snapshotRef ?? null,
sync_requested: Boolean(detail.activate),
}),
}).catch(() => undefined);
if (status.state !== "connected" && snapshotRef) { if (status.state !== "connected" && snapshotRef) {
void loadRecordedNvimGrid(detail.step).then(saved => { void loadRecordedNvimGrid(detail.step).then(saved => {
if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return; if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
@@ -1139,8 +1167,15 @@ function NeovimPanel({
syncReady, syncReady,
controlMode, controlMode,
controlEnabled, controlEnabled,
screenCursor: { ...gridRef.current.cursor },
grid: {
columns: gridRef.current.width,
rows: gridRef.current.height,
},
}); });
}, [activeStep, checkpoint, controlEnabled, controlMode, onSummaryChange, status, syncMode, syncReady]); }, [activeStep, checkpoint, controlEnabled, controlMode, gridRevision, onSummaryChange, status, syncMode, syncReady]);
useEffect(() => () => window.clearTimeout(gridReportTimerRef.current), []);
useEffect(() => { useEffect(() => {
if (!controlEnabled) return; if (!controlEnabled) return;
@@ -1214,6 +1249,12 @@ function NeovimPanel({
)) { )) {
gridFlushRef.current += 1; gridFlushRef.current += 1;
scheduleRedraw(); scheduleRedraw();
if (!gridReportTimerRef.current) {
gridReportTimerRef.current = window.setTimeout(() => {
gridReportTimerRef.current = 0;
setGridRevision(current => current + 1);
}, 120);
}
} }
} }
} catch { } catch {
@@ -1786,7 +1827,7 @@ function LegacyInteractiveUmlStages({
); );
const isCompleted = Boolean( const isCompleted = Boolean(
item.progress_id && item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed", progress?.items?.[item.progress_id]?.status === "approved",
); );
sameRegion.forEach(({ rect }, duplicateIndex) => { sameRegion.forEach(({ rect }, duplicateIndex) => {
const isBackground = rect.getAttribute("fill") !== "none"; const isBackground = rect.getAttribute("fill") !== "none";
@@ -1854,14 +1895,14 @@ function LegacyInteractiveUmlStages({
aria-pressed={stageIndex === index} aria-pressed={stageIndex === index}
data-completed={ data-completed={
item.progress_id && item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed" progress?.items?.[item.progress_id]?.status === "approved"
? "true" ? "true"
: "false" : "false"
} }
onClick={() => setStageIndex(index)} onClick={() => setStageIndex(index)}
> >
{item.progress_id && {item.progress_id &&
progress?.items?.[item.progress_id]?.status === "completed" && ( progress?.items?.[item.progress_id]?.status === "approved" && (
<span aria-hidden="true"> </span> <span aria-hidden="true"> </span>
)} )}
{item.label} {item.label}
@@ -1901,7 +1942,7 @@ function LegacyInteractiveUmlStages({
{stage?.progress_id && ( {stage?.progress_id && (
<footer className="uml-approval"> <footer className="uml-approval">
<span> <span>
{progress?.items?.[stage.progress_id]?.status === "completed" {progress?.items?.[stage.progress_id]?.status === "approved"
? "Ten stan jest zatwierdzony i zapisany w JSON-ie zajęć." ? "Ten stan jest zatwierdzony i zapisany w JSON-ie zajęć."
: "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."} : "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."}
</span> </span>
@@ -1910,12 +1951,12 @@ function LegacyInteractiveUmlStages({
onClick={async () => { onClick={async () => {
if (!stage.progress_id || !progress) return; if (!stage.progress_id || !progress) return;
const completed = const completed =
progress.items?.[stage.progress_id]?.status === "completed"; progress.items?.[stage.progress_id]?.status === "approved";
setApprovalBusy(true); setApprovalBusy(true);
try { try {
await onSetStatus( await onSetStatus(
stage.progress_id, stage.progress_id,
completed ? "not_started" : "completed", completed ? "pending" : "approved",
); );
} finally { } finally {
setApprovalBusy(false); setApprovalBusy(false);
@@ -1924,7 +1965,7 @@ function LegacyInteractiveUmlStages({
> >
{approvalBusy {approvalBusy
? "Zapisywanie…" ? "Zapisywanie…"
: progress?.items?.[stage.progress_id]?.status === "completed" : progress?.items?.[stage.progress_id]?.status === "approved"
? "Cofnij zatwierdzenie" ? "Cofnij zatwierdzenie"
: "Zatwierdź stan"} : "Zatwierdź stan"}
</button> </button>
@@ -1951,11 +1992,15 @@ function SnapshotInteractiveUmlSequence({
); );
const storageKey = `${config.storage_key ?? asset.label}-uml-step`; const storageKey = `${config.storage_key ?? asset.label}-uml-step`;
const viewStorageKey = `${config.storage_key ?? asset.label}-evidence-view`; const viewStorageKey = `${config.storage_key ?? asset.label}-evidence-view`;
const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`;
const rootRef = useRef<HTMLDivElement>(null); const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const activateOnChangeRef = useRef(false); const activateOnChangeRef = useRef(false);
const focusOnChangeRef = useRef<NavigationLevel | null>(null);
const persistOnChangeRef = useRef(true);
const [svgMarkup, setSvgMarkup] = useState(""); const [svgMarkup, setSvgMarkup] = useState("");
const [svgFailed, setSvgFailed] = useState(false); const [svgFailed, setSvgFailed] = useState(false);
const [revealRevision, setRevealRevision] = useState(0);
const [approvalBusy, setApprovalBusy] = useState(false); const [approvalBusy, setApprovalBusy] = useState(false);
const [evidenceView, setEvidenceView] = useState<"code" | "terminal">(() => const [evidenceView, setEvidenceView] = useState<"code" | "terminal">(() =>
localStorage.getItem(viewStorageKey) === "terminal" ? "terminal" : "code", localStorage.getItem(viewStorageKey) === "terminal" ? "terminal" : "code",
@@ -1969,6 +2014,13 @@ function SnapshotInteractiveUmlSequence({
), ),
), ),
); );
const [focusLevel, setFocusLevel] = useState<NavigationLevel>(() => {
const saved = localStorage.getItem(focusStorageKey);
return saved === "task" || saved === "block" || saved === "phase"
|| saved === "step" || saved === "snapshot"
? saved
: "step";
});
useEffect( useEffect(
() => localStorage.setItem(storageKey, String(stepIndex)), () => localStorage.setItem(storageKey, String(stepIndex)),
@@ -1978,6 +2030,10 @@ function SnapshotInteractiveUmlSequence({
() => localStorage.setItem(viewStorageKey, evidenceView), () => localStorage.setItem(viewStorageKey, evidenceView),
[evidenceView, viewStorageKey], [evidenceView, viewStorageKey],
); );
useEffect(
() => localStorage.setItem(focusStorageKey, focusLevel),
[focusLevel, focusStorageKey],
);
useEffect(() => { useEffect(() => {
let current = true; let current = true;
setSvgFailed(false); setSvgFailed(false);
@@ -2048,7 +2104,7 @@ function SnapshotInteractiveUmlSequence({
const arrowY = labelY + 5; const arrowY = labelY + 5;
const completed = Boolean( const completed = Boolean(
step.progress_id && step.progress_id &&
progress?.items?.[step.progress_id]?.status === "completed", progress?.items?.[step.progress_id]?.status === "approved",
); );
const related: SVGElement[] = texts.filter( const related: SVGElement[] = texts.filter(
(text) => Math.abs(Number(text.getAttribute("y")) - labelY) < 0.8, (text) => Math.abs(Number(text.getAttribute("y")) - labelY) < 0.8,
@@ -2106,29 +2162,91 @@ function SnapshotInteractiveUmlSequence({
}); });
}, [asset.alt, entries, progress, stepIndex, svgMarkup]); }, [asset.alt, entries, progress, stepIndex, svgMarkup]);
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
const chrome = document.querySelector<HTMLElement>(".viewer-chrome");
if (chrome?.classList.contains("is-nvim-fit")) return;
const target = canvasRef.current?.querySelector<SVGRectElement>(
`.uml-step-hit[data-uml-step="${stepIndex}"]`,
) ?? rootRef.current;
if (!target) return;
const bounds = target.getBoundingClientRect();
const chromeBounds = chrome?.getBoundingClientRect();
const visibleTop = Math.max(
0,
chromeBounds && chromeBounds.bottom > 0 ? chromeBounds.bottom : 0,
) + 12;
const visibleBottom = window.innerHeight - 12;
let delta = 0;
if (bounds.height > visibleBottom - visibleTop) {
delta = bounds.top - visibleTop;
} else if (bounds.top < visibleTop) delta = bounds.top - visibleTop;
else if (bounds.bottom > visibleBottom) delta = bounds.bottom - visibleBottom;
if (Math.abs(delta) > 1) {
const previousBehavior = document.documentElement.style.scrollBehavior;
document.documentElement.style.scrollBehavior = "auto";
window.scrollBy({ top: delta, behavior: "auto" });
window.requestAnimationFrame(() => {
document.documentElement.style.scrollBehavior = previousBehavior;
});
}
});
return () => window.cancelAnimationFrame(frame);
}, [revealRevision, stepIndex, svgMarkup]);
const publishEntry = ( const publishEntry = (
entry: { phase: UmlSequencePhase; step: UmlSequenceStep }, entry: { phase: UmlSequencePhase; step: UmlSequenceStep },
activate: boolean, activate: boolean,
level: NavigationLevel = focusLevel,
persist = true,
) => { ) => {
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", { const detail: ActiveNvimStep = {
detail: {
task: config.task ?? null, task: config.task ?? null,
block: config.block ?? null, block: config.block ?? null,
phase: { id: entry.phase.id, label: entry.phase.label }, phase: { id: entry.phase.id, label: entry.phase.label },
step: entry.step, step: entry.step,
focusLevel: level,
activate: activate && document.documentElement.dataset.nvimSyncMode === "on", activate: activate && document.documentElement.dataset.nvimSyncMode === "on",
},
}));
}; };
const selectIndex = (next: number, activate = true) => { window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", { detail }));
if (!persist) return;
void fetch("/api/navigation/current", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
task: detail.task,
block: detail.block,
phase: detail.phase,
step: {
id: detail.step.id,
number: detail.step.number,
label: detail.step.label,
},
snapshot_ref: detail.step.snapshot_ref ?? null,
focus_level: detail.focusLevel,
sync_requested: Boolean(detail.activate),
actor: document.documentElement.dataset.cardClientId ?? "browser",
}),
}).catch(() => undefined);
};
const selectIndex = (
next: number,
activate = true,
level: NavigationLevel = focusLevel,
persist = true,
) => {
const bounded = Math.max(0, Math.min(entries.length - 1, next)); const bounded = Math.max(0, Math.min(entries.length - 1, next));
const entry = entries[bounded]; const entry = entries[bounded];
if (!entry) return; if (!entry) return;
setFocusLevel(level);
setRevealRevision(current => current + 1);
if (bounded === stepIndex) { if (bounded === stepIndex) {
publishEntry(entry, activate); publishEntry(entry, activate, level, persist);
return; return;
} }
activateOnChangeRef.current = activate; activateOnChangeRef.current = activate;
focusOnChangeRef.current = level;
persistOnChangeRef.current = persist;
setStepIndex(bounded); setStepIndex(bounded);
}; };
const selectStepAtPoint = (clientX: number, clientY: number) => { const selectStepAtPoint = (clientX: number, clientY: number) => {
@@ -2155,7 +2273,7 @@ function SnapshotInteractiveUmlSequence({
); );
}); });
const next = Number(hits[0]?.dataset.umlStep); const next = Number(hits[0]?.dataset.umlStep);
if (Number.isInteger(next)) selectIndex(next); if (Number.isInteger(next)) selectIndex(next, true, "step");
}; };
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => { const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Enter" && event.key !== " ") return; if (event.key !== "Enter" && event.key !== " ") return;
@@ -2163,22 +2281,45 @@ function SnapshotInteractiveUmlSequence({
const next = Number(target.dataset?.umlStep); const next = Number(target.dataset?.umlStep);
if (!Number.isInteger(next)) return; if (!Number.isInteger(next)) return;
event.preventDefault(); event.preventDefault();
selectIndex(next); selectIndex(next, true, "step");
}; };
const selectPhase = (phase: UmlSequencePhase) => { const selectPhase = (phase: UmlSequencePhase) => {
const next = entries.findIndex((entry) => entry.phase.id === phase.id); const next = entries.findIndex((entry) => entry.phase.id === phase.id);
if (next >= 0) selectIndex(next); if (next >= 0) selectIndex(next, true, "phase");
}; };
const active = entries[stepIndex]; const active = entries[stepIndex];
useEffect(() => { useEffect(() => {
if (!active) return; if (!active) return;
publishEntry(active, activateOnChangeRef.current); publishEntry(
active,
activateOnChangeRef.current,
focusOnChangeRef.current ?? focusLevel,
persistOnChangeRef.current,
);
activateOnChangeRef.current = false; activateOnChangeRef.current = false;
focusOnChangeRef.current = null;
persistOnChangeRef.current = true;
}, [active?.phase.id, active?.step.id, config.block]); }, [active?.phase.id, active?.step.id, config.block]);
useEffect(() => {
const applyCommand = (event: Event) => {
const command = (event as CustomEvent<ApiNavigation>).detail;
if (!command) return;
if (config.task?.id && command.task.id !== config.task.id) return;
if (config.block?.id && command.block.id !== config.block.id) return;
const next = entries.findIndex(entry =>
entry.phase.id === command.phase.id && entry.step.id === command.step.id,
);
if (next < 0) return;
selectIndex(next, command.sync_requested, command.focus_level, false);
};
window.addEventListener("stem:navigation-command", applyCommand);
return () => window.removeEventListener("stem:navigation-command", applyCommand);
}, [config.block?.id, config.task?.id, entries, focusLevel, stepIndex]);
useEffect(() => { useEffect(() => {
const navigate = (event: KeyboardEvent) => { const navigate = (event: KeyboardEvent) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
if (event.metaKey) return; if (event.metaKey) return;
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return; if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
@@ -2195,45 +2336,79 @@ function SnapshotInteractiveUmlSequence({
- Math.abs((b.top + b.bottom) / 2 - viewportCenter); - Math.abs((b.top + b.bottom) / 2 - viewportCenter);
})[0]; })[0];
if (nearest !== root) return; if (nearest !== root) return;
const direction = event.key === "ArrowLeft" ? -1 : 1;
if (event.ctrlKey && event.shiftKey) { if (event.key === "Enter" && event.ctrlKey && !event.altKey && !event.shiftKey) {
const snapshots = entries.filter((entry, index) => if (!active?.step.progress_id || !progress) return;
Boolean(entry.step.snapshot_ref)
&& entries.findIndex(candidate =>
candidate.step.snapshot_ref === entry.step.snapshot_ref,
) === index,
);
const current = snapshots.findIndex(entry =>
entry.step.snapshot_ref === active?.step.snapshot_ref,
);
const next = snapshots[Math.max(0, Math.min(snapshots.length - 1, current + direction))];
if (!next) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
selectIndex(entries.indexOf(next)); const approved = progress.items?.[active.step.progress_id]?.status === "approved";
void onSetStatus(active.step.progress_id, approved ? "pending" : "approved");
return; return;
} }
if (event.ctrlKey || event.altKey) return;
if (event.key === "Enter" && !event.altKey && !event.shiftKey) {
if (!active) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (event.shiftKey) { publishEntry(active, true, focusLevel);
const current = phases.findIndex(item => item.id === active?.phase.id); return;
const next = phases[Math.max(0, Math.min(phases.length - 1, current + direction))];
if (next) selectPhase(next);
} else {
selectIndex(stepIndex + direction);
} }
const hierarchy: NavigationLevel[] = ["task", "block", "phase", "step", "snapshot"];
if ((event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Escape")
&& !event.altKey && !event.ctrlKey && !event.shiftKey) {
const direction = event.key === "ArrowRight" ? 1 : -1;
const nextLevel = hierarchy[Math.max(
0,
Math.min(hierarchy.length - 1, hierarchy.indexOf(focusLevel) + direction),
)];
event.preventDefault();
event.stopPropagation();
setFocusLevel(nextLevel);
if (active) publishEntry(active, false, nextLevel);
return;
}
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
const direction = event.key === "ArrowUp" ? -1 : 1;
let level: NavigationLevel = focusLevel;
if (event.altKey && event.shiftKey && !event.ctrlKey) level = "snapshot";
else if (event.ctrlKey && event.shiftKey && !event.altKey) level = "step";
else if (event.altKey && !event.ctrlKey && !event.shiftKey) level = "task";
else if (event.ctrlKey && !event.altKey && !event.shiftKey) level = "block";
else if (event.shiftKey && !event.altKey && !event.ctrlKey) level = "phase";
else if (event.altKey || event.ctrlKey || event.shiftKey) return;
event.preventDefault();
event.stopPropagation();
void fetch("/api/navigation/move", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
level,
delta: direction,
sync_requested: document.documentElement.dataset.nvimSyncMode === "on",
actor: document.documentElement.dataset.cardClientId ?? "browser",
}),
})
.then(response => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
.then(payload => {
if (payload.current) {
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
detail: payload.current,
}));
}
})
.catch(() => undefined);
}; };
window.addEventListener("keydown", navigate); window.addEventListener("keydown", navigate);
return () => window.removeEventListener("keydown", navigate); return () => window.removeEventListener("keydown", navigate);
}, [active?.phase.id, active?.step.snapshot_ref, entries, phases, stepIndex]); }, [active, entries, focusLevel, onSetStatus, progress]);
if (!active) return null; if (!active) return null;
const { phase, step } = active; const { phase, step } = active;
const snapshot = step.snapshot; const snapshot = step.snapshot;
const completed = Boolean( const completed = Boolean(
step.progress_id && step.progress_id &&
progress?.items?.[step.progress_id]?.status === "completed", progress?.items?.[step.progress_id]?.status === "approved",
); );
const phaseEntries = entries.filter((entry) => entry.phase.id === phase.id); const phaseEntries = entries.filter((entry) => entry.phase.id === phase.id);
const arena = snapshot.memory.arena; const arena = snapshot.memory.arena;
@@ -2283,6 +2458,9 @@ function SnapshotInteractiveUmlSequence({
ref={rootRef} ref={rootRef}
className="uml-react uml-snapshot-react" className="uml-react uml-snapshot-react"
data-stem-nav="uml-block" data-stem-nav="uml-block"
data-task-id={config.task?.id}
data-block-id={config.block?.id}
data-navigation-level={focusLevel}
> >
<header className="uml-snapshot-header"> <header className="uml-snapshot-header">
<div> <div>
@@ -2308,7 +2486,7 @@ function SnapshotInteractiveUmlSequence({
const index = entries.indexOf(entry); const index = entries.indexOf(entry);
const isDone = Boolean( const isDone = Boolean(
entry.step.progress_id && entry.step.progress_id &&
progress?.items?.[entry.step.progress_id]?.status === "completed", progress?.items?.[entry.step.progress_id]?.status === "approved",
); );
return ( return (
<button <button
@@ -2567,7 +2745,7 @@ function SnapshotInteractiveUmlSequence({
try { try {
await onSetStatus( await onSetStatus(
step.progress_id, step.progress_id,
completed ? "not_started" : "completed", completed ? "pending" : "approved",
); );
} finally { } finally {
setApprovalBusy(false); setApprovalBusy(false);
@@ -2674,18 +2852,17 @@ function StepMap({
}) { }) {
if (!steps.length) return null; if (!steps.length) return null;
const labels: Record<Status, string> = { const labels: Record<Status, string> = {
not_started: "○ do omówienia", pending: "○ niezatwierdzony",
discussed: "◐ omówione", approved: "● zatwierdzony",
completed: "● wykonane",
}; };
return ( return (
<div className="step-map"> <div className="step-map">
{steps.map((step) => { {steps.map((step) => {
const status: Status = const status: Status =
progress?.items?.[step.id]?.status ?? "not_started"; progress?.items?.[step.id]?.status ?? "pending";
return ( return (
<div <div
className={`step-row ${status === "completed" ? "lesson-progress-completed" : ""}`} className={`step-row ${status === "approved" ? "lesson-progress-completed" : ""}`}
key={step.id} key={step.id}
> >
<button <button
@@ -2774,8 +2951,7 @@ function ProgressDock({
<strong>Przebieg zajęć</strong> <strong>Przebieg zajęć</strong>
{counts && ( {counts && (
<p> <p>
Wykonane: {counts.completed}/{progress!.summary.total} · omówione:{" "} Zatwierdzone: {counts.approved}/{progress!.summary.total}
{counts.discussed}/{progress!.summary.total}
</p> </p>
)} )}
{error && <p className="lesson-progress-error">{error}</p>} {error && <p className="lesson-progress-error">{error}</p>}
@@ -2789,11 +2965,16 @@ function ProgressDock({
function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) { function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
if (!open) return null; if (!open) return null;
const rows = [ const rows = [
[" / ", "poprzedni / następny STEP"], ["Alt + ↑ / ", "poprzedni / następny TASK"],
["Shift + / ", "poprzednia / następna PHASE"], ["Ctrl + / ", "poprzedni / następny BLOCK"],
["Ctrl + Shift + / ", "poprzedni / następny unikalny SNAPSHOT"], ["Shift + / ", "poprzednia / następna PHASE"],
["Alt + / ", "poprzedni / następny BLOCK"], ["Ctrl + Shift + / ", "poprzedni / następny STEP"],
["Ctrl + / ", "poprzedni / następny TASK"], ["Alt + Shift + / ", "poprzedni / następny unikalny SNAPSHOT"],
["↑ / ↓", "poprzedni / następny element aktywnego poziomu"],
["→", "zejdź poziom niżej"],
["← / Esc", "wróć poziom wyżej"],
["Enter", "wybierz element; przy SYNC ON odtwórz stan"],
["Ctrl + Enter", "zatwierdź lub cofnij zatwierdzenie kroku"],
["Alt + 1", "pokaż lub ukryj panel Neovima"], ["Alt + 1", "pokaż lub ukryj panel Neovima"],
["Alt + 2", "przełącz SYNC OFF / SYNC ON"], ["Alt + 2", "przełącz SYNC OFF / SYNC ON"],
["Alt + 3", "uzbrój lub rozbrój sterowanie Neovimem"], ["Alt + 3", "uzbrój lub rozbrój sterowanie Neovimem"],
@@ -2802,9 +2983,7 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
["Alt + 3, potem najedź", "przekaż klawiaturę i mysz do aktywnej sesji"], ["Alt + 3, potem najedź", "przekaż klawiaturę i mysz do aktywnej sesji"],
["Alt + W", "prefiks poleceń okien Neovima (<C-W>)"], ["Alt + W", "prefiks poleceń okien Neovima (<C-W>)"],
["Alt + strzałka", "przejdź do sąsiedniego okna Neovima"], ["Alt + strzałka", "przejdź do sąsiedniego okna Neovima"],
["Zatwierdź krok", "zapisz bieżący step i timestamp do raportu"],
["F12", "pokaż lub ukryj ten skorowidz"], ["F12", "pokaż lub ukryj ten skorowidz"],
["Esc", "zamknij skorowidz"],
]; ];
return ( return (
<div className="shortcut-help-backdrop" role="presentation" onMouseDown={onClose}> <div className="shortcut-help-backdrop" role="presentation" onMouseDown={onClose}>
@@ -2843,6 +3022,11 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
function App({ model }: { model: CardModel }) { function App({ model }: { model: CardModel }) {
const viewerChromeRef = useRef<HTMLDivElement>(null); const viewerChromeRef = useRef<HTMLDivElement>(null);
const clientIdRef = useRef(
`browser-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`,
);
const viewerRevisionRef = useRef(0);
const navigationRevisionRef = useRef(0);
const [scale, setScaleState] = useState(2.18); const [scale, setScaleState] = useState(2.18);
const [nvimVisible, setNvimVisibleState] = useState( const [nvimVisible, setNvimVisibleState] = useState(
() => localStorage.getItem("stem-card-nvim-visible") !== "false", () => localStorage.getItem("stem-card-nvim-visible") !== "false",
@@ -2866,8 +3050,28 @@ function App({ model }: { model: CardModel }) {
const [progress, setProgress] = useState<Progress | null>(null); const [progress, setProgress] = useState<Progress | null>(null);
const [progressError, setProgressError] = useState(""); const [progressError, setProgressError] = useState("");
const [shortcutsOpen, setShortcutsOpen] = useState(false); const [shortcutsOpen, setShortcutsOpen] = useState(false);
const setScale = (value: number) => const publishViewer = useCallback(async (viewer: Record<string, unknown>) => {
setScaleState(Math.max(0.25, Math.min(3, value))); try {
const response = await fetch("/api/viewer/state", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ actor: clientIdRef.current, viewer }),
});
if (!response.ok) return;
const payload = await response.json();
viewerRevisionRef.current = Math.max(
viewerRevisionRef.current,
Number(payload.viewer?.revision ?? 0),
);
} catch {
// Statyczny HTML pozostaje użyteczny bez lokalnego API sterowania.
}
}, []);
const setScale = (value: number) => {
const next = Math.max(0.25, Math.min(3, value));
setScaleState(next);
void publishViewer({ scale: next });
};
useEffect(() => { useEffect(() => {
document.documentElement.style.setProperty( document.documentElement.style.setProperty(
"--viewer-canvas-scale", "--viewer-canvas-scale",
@@ -2877,15 +3081,22 @@ function App({ model }: { model: CardModel }) {
useEffect(() => { useEffect(() => {
document.documentElement.dataset.nvimSyncMode = nvimSyncMode ? "on" : "off"; document.documentElement.dataset.nvimSyncMode = nvimSyncMode ? "on" : "off";
}, [nvimSyncMode]); }, [nvimSyncMode]);
useEffect(() => {
document.documentElement.dataset.cardClientId = clientIdRef.current;
}, []);
const setNvimVisible = (value: boolean) => { const setNvimVisible = (value: boolean) => {
setNvimVisibleState(value); setNvimVisibleState(value);
if (!value) setNvimExpandedState(false); if (!value) setNvimExpandedState(false);
localStorage.setItem("stem-card-nvim-visible", String(value)); localStorage.setItem("stem-card-nvim-visible", String(value));
void publishViewer({
nvim: { visible: value, ...(value ? {} : { expanded: false }) },
});
}; };
const setNvimControlMode = (value: boolean) => { const setNvimControlMode = (value: boolean) => {
const next = value && nvimSyncMode; const next = value && nvimSyncMode;
setNvimControlModeState(next); setNvimControlModeState(next);
localStorage.setItem("stem-card-nvim-control-mode", String(next)); localStorage.setItem("stem-card-nvim-control-mode", String(next));
void publishViewer({ nvim: { control: next } });
}; };
const setNvimSyncMode = (value: boolean) => { const setNvimSyncMode = (value: boolean) => {
setNvimSyncModeState(value); setNvimSyncModeState(value);
@@ -2894,11 +3105,13 @@ function App({ model }: { model: CardModel }) {
setNvimControlModeState(false); setNvimControlModeState(false);
localStorage.setItem("stem-card-nvim-control-mode", "false"); localStorage.setItem("stem-card-nvim-control-mode", "false");
} }
void publishViewer({ nvim: { sync: value, control: value ? nvimControlMode : false } });
}; };
const setNvimExpanded = (value: boolean) => { const setNvimExpanded = (value: boolean) => {
if (value) setNvimVisible(true); if (value) setNvimVisible(true);
if (value) setNvimFitState(false); if (value) setNvimFitState(false);
setNvimExpandedState(value); setNvimExpandedState(value);
void publishViewer({ nvim: { visible: value || nvimVisible, expanded: value, fit: false } });
}; };
const setNvimFit = (value: boolean) => { const setNvimFit = (value: boolean) => {
if (value) { if (value) {
@@ -2906,6 +3119,7 @@ function App({ model }: { model: CardModel }) {
setNvimExpandedState(false); setNvimExpandedState(false);
} }
setNvimFitState(value); setNvimFitState(value);
void publishViewer({ nvim: { visible: value || nvimVisible, fit: value, expanded: false } });
}; };
useEffect(() => { useEffect(() => {
let second = 0; let second = 0;
@@ -2933,6 +3147,108 @@ function App({ model }: { model: CardModel }) {
), ),
); );
}, []); }, []);
useEffect(() => {
let cancelled = false;
let initialised = false;
const initialViewer = {
scale,
viewport: { width: window.innerWidth, height: window.innerHeight },
nvim: {
visible: nvimVisible,
sync: nvimSyncMode,
control: nvimControlMode,
expanded: nvimExpanded,
fit: nvimFit,
},
};
const applyViewer = (viewer: ApiViewerState) => {
setScaleState(Math.max(0.25, Math.min(3, viewer.scale)));
setNvimVisibleState(viewer.nvim.visible);
setNvimSyncModeState(viewer.nvim.sync);
setNvimControlModeState(viewer.nvim.sync && viewer.nvim.control);
setNvimExpandedState(viewer.nvim.expanded);
setNvimFitState(viewer.nvim.fit);
localStorage.setItem("stem-card-nvim-visible", String(viewer.nvim.visible));
localStorage.setItem("stem-card-nvim-sync-mode", String(viewer.nvim.sync));
localStorage.setItem(
"stem-card-nvim-control-mode",
String(viewer.nvim.sync && viewer.nvim.control),
);
if (Math.abs(window.scrollX - viewer.scroll.x) > 1
|| Math.abs(window.scrollY - viewer.scroll.y) > 1) {
window.scrollTo({ left: viewer.scroll.x, top: viewer.scroll.y, behavior: "smooth" });
}
};
const poll = async () => {
try {
const response = await fetch("/api/control/state", { cache: "no-store" });
if (!response.ok || cancelled) return;
const payload = await response.json();
const viewer = payload.viewer as ApiViewerState;
if (!initialised) {
initialised = true;
if (Number(viewer?.revision ?? 0) === 0) void publishViewer(initialViewer);
}
if (viewer && viewer.revision > viewerRevisionRef.current) {
viewerRevisionRef.current = viewer.revision;
if (viewer.actor !== clientIdRef.current) applyViewer(viewer);
}
const navigation = payload.navigation as ApiNavigation | null;
if (navigation && navigation.revision > navigationRevisionRef.current) {
navigationRevisionRef.current = navigation.revision;
if (navigation.actor !== clientIdRef.current) {
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
detail: navigation,
}));
}
}
} catch {
// Tryb statyczny nie ma API; lokalne skróty nadal pozostają dostępne.
}
};
void poll();
const timer = window.setInterval(poll, 300);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
void publishViewer({
nvim: {
connection: nvimSummary.state,
screen_cursor: nvimSummary.screenCursor,
grid: nvimSummary.grid,
},
});
}, [nvimSummary.grid?.columns, nvimSummary.grid?.rows, nvimSummary.screenCursor?.column, nvimSummary.screenCursor?.row, nvimSummary.state, publishViewer]);
useEffect(() => {
let timer = 0;
const report = () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
const sheets = Array.from(document.querySelectorAll<HTMLElement>(".paper > .sheet"));
const center = window.scrollY + window.innerHeight / 2;
const pageIndex = sheets.reduce((best, sheet, index) => {
const top = sheet.offsetTop;
const distance = Math.abs(top + sheet.offsetHeight / 2 - center);
return distance < best.distance ? { index, distance } : best;
}, { index: 0, distance: Number.POSITIVE_INFINITY }).index;
void publishViewer({
scroll: { x: window.scrollX, y: window.scrollY, page_index: pageIndex },
viewport: { width: window.innerWidth, height: window.innerHeight },
});
}, 120);
};
window.addEventListener("scroll", report, { passive: true });
window.addEventListener("resize", report);
report();
return () => {
window.clearTimeout(timer);
window.removeEventListener("scroll", report);
window.removeEventListener("resize", report);
};
}, [publishViewer]);
useEffect(() => { useEffect(() => {
const navigate = (event: KeyboardEvent) => { const navigate = (event: KeyboardEvent) => {
if (event.repeat) return; if (event.repeat) return;
@@ -2942,63 +3258,33 @@ function App({ model }: { model: CardModel }) {
if (browserCommand === "Digit1") { if (browserCommand === "Digit1") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setNvimVisibleState(current => { setNvimVisible(!nvimVisible);
const next = !current;
if (!next) setNvimExpandedState(false);
localStorage.setItem("stem-card-nvim-visible", String(next));
return next;
});
return; return;
} }
if (browserCommand === "Digit2") { if (browserCommand === "Digit2") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setNvimSyncModeState(current => { setNvimSyncMode(!nvimSyncMode);
const next = !current;
localStorage.setItem("stem-card-nvim-sync-mode", String(next));
if (!next) {
setNvimControlModeState(false);
localStorage.setItem("stem-card-nvim-control-mode", "false");
}
return next;
});
return; return;
} }
if (browserCommand === "Digit3") { if (browserCommand === "Digit3") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
if (!nvimSyncMode) return; if (!nvimSyncMode) return;
setNvimVisibleState(true); setNvimVisible(true);
localStorage.setItem("stem-card-nvim-visible", "true"); setNvimControlMode(!nvimControlMode);
setNvimControlModeState(current => {
const next = !current;
localStorage.setItem("stem-card-nvim-control-mode", String(next));
return next;
});
return; return;
} }
if (browserCommand === "Digit4") { if (browserCommand === "Digit4") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setNvimVisibleState(true); setNvimExpanded(!nvimExpanded);
localStorage.setItem("stem-card-nvim-visible", "true");
setNvimExpandedState(current => {
const next = !current;
if (next) setNvimFitState(false);
return next;
});
return; return;
} }
if (browserCommand === "Digit5") { if (browserCommand === "Digit5") {
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
setNvimVisibleState(true); setNvimFit(!nvimFit);
localStorage.setItem("stem-card-nvim-visible", "true");
setNvimFitState(current => {
const next = !current;
if (next) setNvimExpandedState(false);
return next;
});
return; return;
} }
if (event.key === "F12") { if (event.key === "F12") {
@@ -3012,51 +3298,30 @@ function App({ model }: { model: CardModel }) {
setShortcutsOpen(false); setShortcutsOpen(false);
return; return;
} }
if (document.activeElement?.closest(".nvim-screen-scroll[data-control='true']")) return;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
const isTask = event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
const isBlock = event.altKey && !event.ctrlKey && !event.metaKey;
if (!isTask && !isBlock) return;
const selector = isTask
? "[data-stem-nav='task']"
: "[data-stem-nav='block'], [data-stem-nav='uml-block']";
const items = Array.from(document.querySelectorAll<HTMLElement>(selector));
if (!items.length) return;
const viewportCenter = window.innerHeight / 2;
const current = items.reduce((best, item, index) => {
const bounds = item.getBoundingClientRect();
const distance = Math.abs((bounds.top + bounds.bottom) / 2 - viewportCenter);
return distance < best.distance ? { index, distance } : best;
}, { index: 0, distance: Number.POSITIVE_INFINITY }).index;
const direction = event.key === "ArrowLeft" ? -1 : 1;
const next = Math.max(0, Math.min(items.length - 1, current + direction));
event.preventDefault();
event.stopPropagation();
items[next].scrollIntoView({ behavior: "smooth", block: "start" });
}; };
window.addEventListener("keydown", navigate, true); window.addEventListener("keydown", navigate, true);
return () => window.removeEventListener("keydown", navigate, true); return () => window.removeEventListener("keydown", navigate, true);
}, [nvimSyncMode, shortcutsOpen]); }, [nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, shortcutsOpen]);
useEffect(() => { useEffect(() => {
const wheel = (event: WheelEvent) => { const wheel = (event: WheelEvent) => {
if (!event.altKey || event.ctrlKey) return; if (!event.altKey || event.ctrlKey) return;
event.preventDefault(); event.preventDefault();
setScaleState((current) => setScaleState((current) => {
Math.max( const next = Math.max(
0.25, 0.25,
Math.min( Math.min(
3, 3,
current * current *
Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001), Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001),
), ),
),
); );
void publishViewer({ scale: next });
return next;
});
}; };
window.addEventListener("wheel", wheel, { passive: false }); window.addEventListener("wheel", wheel, { passive: false });
return () => window.removeEventListener("wheel", wheel); return () => window.removeEventListener("wheel", wheel);
}, []); }, [publishViewer]);
const setStatus: SetProgressStatus = async (id, status) => { const setStatus: SetProgressStatus = async (id, status) => {
try { try {
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, { const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
@@ -3075,9 +3340,8 @@ function App({ model }: { model: CardModel }) {
}; };
const cycle = (id: string, current: Status) => { const cycle = (id: string, current: Status) => {
const next: Record<Status, Status> = { const next: Record<Status, Status> = {
not_started: "discussed", pending: "approved",
discussed: "completed", approved: "pending",
completed: "not_started",
}; };
void setStatus(id, next[current]); void setStatus(id, next[current]);
}; };
+1 -5
View File
@@ -1392,11 +1392,7 @@ text.uml-step-element-active {
cursor: wait; cursor: wait;
opacity: 0.6; opacity: 0.6;
} }
.lesson-progress-control[data-status="discussed"] { .lesson-progress-control[data-status="approved"] {
background: #fff4cc;
border-color: #9b6a00;
}
.lesson-progress-control[data-status="completed"] {
background: #e7f6ed; background: #e7f6ed;
border-color: #18794e; border-color: #18794e;
} }