feat: add hierarchical lesson session controls
This commit is contained in:
+410
-146
@@ -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 "./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 InteractiveSymbol = {
|
||||
id: string;
|
||||
@@ -204,6 +243,8 @@ type NvimPanelSummary = {
|
||||
syncReady: boolean;
|
||||
controlMode: boolean;
|
||||
controlEnabled: boolean;
|
||||
screenCursor?: { row: number; column: number };
|
||||
grid?: { columns: number; rows: number };
|
||||
};
|
||||
|
||||
const nvimStatusLabel: Record<NvimPanelSummary["state"], string> = {
|
||||
@@ -408,6 +449,7 @@ type ActiveNvimStep = {
|
||||
block: { id?: string; label?: string } | null;
|
||||
phase: { id: string; label: string };
|
||||
step: UmlSequenceStep;
|
||||
focusLevel?: NavigationLevel;
|
||||
activate?: boolean;
|
||||
};
|
||||
type NvimConnectionStatus = {
|
||||
@@ -901,6 +943,7 @@ function NeovimPanel({
|
||||
const websocketRef = useRef<WebSocket | null>(null);
|
||||
const redrawFrameRef = useRef(0);
|
||||
const gridFlushRef = useRef(0);
|
||||
const gridReportTimerRef = useRef(0);
|
||||
const gridRef = useRef<NvimGridModel>(createNvimGrid());
|
||||
const targetEpochRef = useRef<string | null>(null);
|
||||
const activeStepRef = useRef<ActiveNvimStep | null>(null);
|
||||
@@ -910,6 +953,7 @@ function NeovimPanel({
|
||||
const resizeStartRef = useRef<{ y: number; heightVh: number } | null>(null);
|
||||
const [activeStep, setActiveStep] = useState<ActiveNvimStep | null>(null);
|
||||
const [hasGrid, setHasGrid] = useState(false);
|
||||
const [gridRevision, setGridRevision] = useState(0);
|
||||
const [pointerInside, setPointerInside] = useState(false);
|
||||
const [panelHeightVh, setPanelHeightVh] = useState(() => {
|
||||
const saved = Number(localStorage.getItem("stem-card-nvim-height-vh"));
|
||||
@@ -947,22 +991,6 @@ function NeovimPanel({
|
||||
setActiveStep(detail);
|
||||
presentedSnapshotRef.current = null;
|
||||
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) {
|
||||
void loadRecordedNvimGrid(detail.step).then(saved => {
|
||||
if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
|
||||
@@ -1139,8 +1167,15 @@ function NeovimPanel({
|
||||
syncReady,
|
||||
controlMode,
|
||||
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(() => {
|
||||
if (!controlEnabled) return;
|
||||
@@ -1214,6 +1249,12 @@ function NeovimPanel({
|
||||
)) {
|
||||
gridFlushRef.current += 1;
|
||||
scheduleRedraw();
|
||||
if (!gridReportTimerRef.current) {
|
||||
gridReportTimerRef.current = window.setTimeout(() => {
|
||||
gridReportTimerRef.current = 0;
|
||||
setGridRevision(current => current + 1);
|
||||
}, 120);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1786,7 +1827,7 @@ function LegacyInteractiveUmlStages({
|
||||
);
|
||||
const isCompleted = Boolean(
|
||||
item.progress_id &&
|
||||
progress?.items?.[item.progress_id]?.status === "completed",
|
||||
progress?.items?.[item.progress_id]?.status === "approved",
|
||||
);
|
||||
sameRegion.forEach(({ rect }, duplicateIndex) => {
|
||||
const isBackground = rect.getAttribute("fill") !== "none";
|
||||
@@ -1854,14 +1895,14 @@ function LegacyInteractiveUmlStages({
|
||||
aria-pressed={stageIndex === index}
|
||||
data-completed={
|
||||
item.progress_id &&
|
||||
progress?.items?.[item.progress_id]?.status === "completed"
|
||||
progress?.items?.[item.progress_id]?.status === "approved"
|
||||
? "true"
|
||||
: "false"
|
||||
}
|
||||
onClick={() => setStageIndex(index)}
|
||||
>
|
||||
{item.progress_id &&
|
||||
progress?.items?.[item.progress_id]?.status === "completed" && (
|
||||
progress?.items?.[item.progress_id]?.status === "approved" && (
|
||||
<span aria-hidden="true">✓ </span>
|
||||
)}
|
||||
{item.label}
|
||||
@@ -1901,7 +1942,7 @@ function LegacyInteractiveUmlStages({
|
||||
{stage?.progress_id && (
|
||||
<footer className="uml-approval">
|
||||
<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ęć."
|
||||
: "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."}
|
||||
</span>
|
||||
@@ -1910,12 +1951,12 @@ function LegacyInteractiveUmlStages({
|
||||
onClick={async () => {
|
||||
if (!stage.progress_id || !progress) return;
|
||||
const completed =
|
||||
progress.items?.[stage.progress_id]?.status === "completed";
|
||||
progress.items?.[stage.progress_id]?.status === "approved";
|
||||
setApprovalBusy(true);
|
||||
try {
|
||||
await onSetStatus(
|
||||
stage.progress_id,
|
||||
completed ? "not_started" : "completed",
|
||||
completed ? "pending" : "approved",
|
||||
);
|
||||
} finally {
|
||||
setApprovalBusy(false);
|
||||
@@ -1924,7 +1965,7 @@ function LegacyInteractiveUmlStages({
|
||||
>
|
||||
{approvalBusy
|
||||
? "Zapisywanie…"
|
||||
: progress?.items?.[stage.progress_id]?.status === "completed"
|
||||
: progress?.items?.[stage.progress_id]?.status === "approved"
|
||||
? "Cofnij zatwierdzenie"
|
||||
: "Zatwierdź stan"}
|
||||
</button>
|
||||
@@ -1951,11 +1992,15 @@ function SnapshotInteractiveUmlSequence({
|
||||
);
|
||||
const storageKey = `${config.storage_key ?? asset.label}-uml-step`;
|
||||
const viewStorageKey = `${config.storage_key ?? asset.label}-evidence-view`;
|
||||
const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const activateOnChangeRef = useRef(false);
|
||||
const focusOnChangeRef = useRef<NavigationLevel | null>(null);
|
||||
const persistOnChangeRef = useRef(true);
|
||||
const [svgMarkup, setSvgMarkup] = useState("");
|
||||
const [svgFailed, setSvgFailed] = useState(false);
|
||||
const [revealRevision, setRevealRevision] = useState(0);
|
||||
const [approvalBusy, setApprovalBusy] = useState(false);
|
||||
const [evidenceView, setEvidenceView] = useState<"code" | "terminal">(() =>
|
||||
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(
|
||||
() => localStorage.setItem(storageKey, String(stepIndex)),
|
||||
@@ -1978,6 +2030,10 @@ function SnapshotInteractiveUmlSequence({
|
||||
() => localStorage.setItem(viewStorageKey, evidenceView),
|
||||
[evidenceView, viewStorageKey],
|
||||
);
|
||||
useEffect(
|
||||
() => localStorage.setItem(focusStorageKey, focusLevel),
|
||||
[focusLevel, focusStorageKey],
|
||||
);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
setSvgFailed(false);
|
||||
@@ -2048,7 +2104,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
const arrowY = labelY + 5;
|
||||
const completed = Boolean(
|
||||
step.progress_id &&
|
||||
progress?.items?.[step.progress_id]?.status === "completed",
|
||||
progress?.items?.[step.progress_id]?.status === "approved",
|
||||
);
|
||||
const related: SVGElement[] = texts.filter(
|
||||
(text) => Math.abs(Number(text.getAttribute("y")) - labelY) < 0.8,
|
||||
@@ -2106,29 +2162,91 @@ function SnapshotInteractiveUmlSequence({
|
||||
});
|
||||
}, [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 = (
|
||||
entry: { phase: UmlSequencePhase; step: UmlSequenceStep },
|
||||
activate: boolean,
|
||||
level: NavigationLevel = focusLevel,
|
||||
persist = true,
|
||||
) => {
|
||||
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", {
|
||||
detail: {
|
||||
const detail: ActiveNvimStep = {
|
||||
task: config.task ?? null,
|
||||
block: config.block ?? null,
|
||||
phase: { id: entry.phase.id, label: entry.phase.label },
|
||||
step: entry.step,
|
||||
focusLevel: level,
|
||||
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 entry = entries[bounded];
|
||||
if (!entry) return;
|
||||
setFocusLevel(level);
|
||||
setRevealRevision(current => current + 1);
|
||||
if (bounded === stepIndex) {
|
||||
publishEntry(entry, activate);
|
||||
publishEntry(entry, activate, level, persist);
|
||||
return;
|
||||
}
|
||||
activateOnChangeRef.current = activate;
|
||||
focusOnChangeRef.current = level;
|
||||
persistOnChangeRef.current = persist;
|
||||
setStepIndex(bounded);
|
||||
};
|
||||
const selectStepAtPoint = (clientX: number, clientY: number) => {
|
||||
@@ -2155,7 +2273,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
);
|
||||
});
|
||||
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>) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
@@ -2163,22 +2281,45 @@ function SnapshotInteractiveUmlSequence({
|
||||
const next = Number(target.dataset?.umlStep);
|
||||
if (!Number.isInteger(next)) return;
|
||||
event.preventDefault();
|
||||
selectIndex(next);
|
||||
selectIndex(next, true, "step");
|
||||
};
|
||||
const selectPhase = (phase: UmlSequencePhase) => {
|
||||
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];
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
publishEntry(active, activateOnChangeRef.current);
|
||||
publishEntry(
|
||||
active,
|
||||
activateOnChangeRef.current,
|
||||
focusOnChangeRef.current ?? focusLevel,
|
||||
persistOnChangeRef.current,
|
||||
);
|
||||
activateOnChangeRef.current = false;
|
||||
focusOnChangeRef.current = null;
|
||||
persistOnChangeRef.current = true;
|
||||
}, [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(() => {
|
||||
const navigate = (event: KeyboardEvent) => {
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
if (event.metaKey) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
||||
@@ -2195,45 +2336,79 @@ function SnapshotInteractiveUmlSequence({
|
||||
- Math.abs((b.top + b.bottom) / 2 - viewportCenter);
|
||||
})[0];
|
||||
if (nearest !== root) return;
|
||||
const direction = event.key === "ArrowLeft" ? -1 : 1;
|
||||
|
||||
if (event.ctrlKey && event.shiftKey) {
|
||||
const snapshots = entries.filter((entry, index) =>
|
||||
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;
|
||||
if (event.key === "Enter" && event.ctrlKey && !event.altKey && !event.shiftKey) {
|
||||
if (!active?.step.progress_id || !progress) return;
|
||||
event.preventDefault();
|
||||
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;
|
||||
}
|
||||
if (event.ctrlKey || event.altKey) return;
|
||||
|
||||
if (event.key === "Enter" && !event.altKey && !event.shiftKey) {
|
||||
if (!active) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.shiftKey) {
|
||||
const current = phases.findIndex(item => item.id === active?.phase.id);
|
||||
const next = phases[Math.max(0, Math.min(phases.length - 1, current + direction))];
|
||||
if (next) selectPhase(next);
|
||||
} else {
|
||||
selectIndex(stepIndex + direction);
|
||||
publishEntry(active, true, focusLevel);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
return () => window.removeEventListener("keydown", navigate);
|
||||
}, [active?.phase.id, active?.step.snapshot_ref, entries, phases, stepIndex]);
|
||||
}, [active, entries, focusLevel, onSetStatus, progress]);
|
||||
if (!active) return null;
|
||||
const { phase, step } = active;
|
||||
const snapshot = step.snapshot;
|
||||
const completed = Boolean(
|
||||
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 arena = snapshot.memory.arena;
|
||||
@@ -2283,6 +2458,9 @@ function SnapshotInteractiveUmlSequence({
|
||||
ref={rootRef}
|
||||
className="uml-react uml-snapshot-react"
|
||||
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">
|
||||
<div>
|
||||
@@ -2308,7 +2486,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
const index = entries.indexOf(entry);
|
||||
const isDone = Boolean(
|
||||
entry.step.progress_id &&
|
||||
progress?.items?.[entry.step.progress_id]?.status === "completed",
|
||||
progress?.items?.[entry.step.progress_id]?.status === "approved",
|
||||
);
|
||||
return (
|
||||
<button
|
||||
@@ -2567,7 +2745,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
try {
|
||||
await onSetStatus(
|
||||
step.progress_id,
|
||||
completed ? "not_started" : "completed",
|
||||
completed ? "pending" : "approved",
|
||||
);
|
||||
} finally {
|
||||
setApprovalBusy(false);
|
||||
@@ -2674,18 +2852,17 @@ function StepMap({
|
||||
}) {
|
||||
if (!steps.length) return null;
|
||||
const labels: Record<Status, string> = {
|
||||
not_started: "○ do omówienia",
|
||||
discussed: "◐ omówione",
|
||||
completed: "● wykonane",
|
||||
pending: "○ niezatwierdzony",
|
||||
approved: "● zatwierdzony",
|
||||
};
|
||||
return (
|
||||
<div className="step-map">
|
||||
{steps.map((step) => {
|
||||
const status: Status =
|
||||
progress?.items?.[step.id]?.status ?? "not_started";
|
||||
progress?.items?.[step.id]?.status ?? "pending";
|
||||
return (
|
||||
<div
|
||||
className={`step-row ${status === "completed" ? "lesson-progress-completed" : ""}`}
|
||||
className={`step-row ${status === "approved" ? "lesson-progress-completed" : ""}`}
|
||||
key={step.id}
|
||||
>
|
||||
<button
|
||||
@@ -2774,8 +2951,7 @@ function ProgressDock({
|
||||
<strong>Przebieg zajęć</strong>
|
||||
{counts && (
|
||||
<p>
|
||||
Wykonane: {counts.completed}/{progress!.summary.total} · omówione:{" "}
|
||||
{counts.discussed}/{progress!.summary.total}
|
||||
Zatwierdzone: {counts.approved}/{progress!.summary.total}
|
||||
</p>
|
||||
)}
|
||||
{error && <p className="lesson-progress-error">{error}</p>}
|
||||
@@ -2789,11 +2965,16 @@ function ProgressDock({
|
||||
function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
if (!open) return null;
|
||||
const rows = [
|
||||
["← / →", "poprzedni / następny STEP"],
|
||||
["Shift + ← / →", "poprzednia / następna PHASE"],
|
||||
["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"],
|
||||
["Alt + ← / →", "poprzedni / następny BLOCK"],
|
||||
["Ctrl + ← / →", "poprzedni / następny TASK"],
|
||||
["Alt + ↑ / ↓", "poprzedni / następny TASK"],
|
||||
["Ctrl + ↑ / ↓", "poprzedni / następny BLOCK"],
|
||||
["Shift + ↑ / ↓", "poprzednia / następna PHASE"],
|
||||
["Ctrl + Shift + ↑ / ↓", "poprzedni / następny STEP"],
|
||||
["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 + 2", "przełącz SYNC OFF / SYNC ON"],
|
||||
["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 + W", "prefiks poleceń okien Neovima (<C-W>)"],
|
||||
["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"],
|
||||
["Esc", "zamknij skorowidz"],
|
||||
];
|
||||
return (
|
||||
<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 }) {
|
||||
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 [nvimVisible, setNvimVisibleState] = useState(
|
||||
() => localStorage.getItem("stem-card-nvim-visible") !== "false",
|
||||
@@ -2866,8 +3050,28 @@ function App({ model }: { model: CardModel }) {
|
||||
const [progress, setProgress] = useState<Progress | null>(null);
|
||||
const [progressError, setProgressError] = useState("");
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const setScale = (value: number) =>
|
||||
setScaleState(Math.max(0.25, Math.min(3, value)));
|
||||
const publishViewer = useCallback(async (viewer: Record<string, unknown>) => {
|
||||
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(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--viewer-canvas-scale",
|
||||
@@ -2877,15 +3081,22 @@ function App({ model }: { model: CardModel }) {
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.nvimSyncMode = nvimSyncMode ? "on" : "off";
|
||||
}, [nvimSyncMode]);
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.cardClientId = clientIdRef.current;
|
||||
}, []);
|
||||
const setNvimVisible = (value: boolean) => {
|
||||
setNvimVisibleState(value);
|
||||
if (!value) setNvimExpandedState(false);
|
||||
localStorage.setItem("stem-card-nvim-visible", String(value));
|
||||
void publishViewer({
|
||||
nvim: { visible: value, ...(value ? {} : { expanded: false }) },
|
||||
});
|
||||
};
|
||||
const setNvimControlMode = (value: boolean) => {
|
||||
const next = value && nvimSyncMode;
|
||||
setNvimControlModeState(next);
|
||||
localStorage.setItem("stem-card-nvim-control-mode", String(next));
|
||||
void publishViewer({ nvim: { control: next } });
|
||||
};
|
||||
const setNvimSyncMode = (value: boolean) => {
|
||||
setNvimSyncModeState(value);
|
||||
@@ -2894,11 +3105,13 @@ function App({ model }: { model: CardModel }) {
|
||||
setNvimControlModeState(false);
|
||||
localStorage.setItem("stem-card-nvim-control-mode", "false");
|
||||
}
|
||||
void publishViewer({ nvim: { sync: value, control: value ? nvimControlMode : false } });
|
||||
};
|
||||
const setNvimExpanded = (value: boolean) => {
|
||||
if (value) setNvimVisible(true);
|
||||
if (value) setNvimFitState(false);
|
||||
setNvimExpandedState(value);
|
||||
void publishViewer({ nvim: { visible: value || nvimVisible, expanded: value, fit: false } });
|
||||
};
|
||||
const setNvimFit = (value: boolean) => {
|
||||
if (value) {
|
||||
@@ -2906,6 +3119,7 @@ function App({ model }: { model: CardModel }) {
|
||||
setNvimExpandedState(false);
|
||||
}
|
||||
setNvimFitState(value);
|
||||
void publishViewer({ nvim: { visible: value || nvimVisible, fit: value, expanded: false } });
|
||||
};
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
const navigate = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
@@ -2942,63 +3258,33 @@ function App({ model }: { model: CardModel }) {
|
||||
if (browserCommand === "Digit1") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimVisibleState(current => {
|
||||
const next = !current;
|
||||
if (!next) setNvimExpandedState(false);
|
||||
localStorage.setItem("stem-card-nvim-visible", String(next));
|
||||
return next;
|
||||
});
|
||||
setNvimVisible(!nvimVisible);
|
||||
return;
|
||||
}
|
||||
if (browserCommand === "Digit2") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimSyncModeState(current => {
|
||||
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;
|
||||
});
|
||||
setNvimSyncMode(!nvimSyncMode);
|
||||
return;
|
||||
}
|
||||
if (browserCommand === "Digit3") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
if (!nvimSyncMode) return;
|
||||
setNvimVisibleState(true);
|
||||
localStorage.setItem("stem-card-nvim-visible", "true");
|
||||
setNvimControlModeState(current => {
|
||||
const next = !current;
|
||||
localStorage.setItem("stem-card-nvim-control-mode", String(next));
|
||||
return next;
|
||||
});
|
||||
setNvimVisible(true);
|
||||
setNvimControlMode(!nvimControlMode);
|
||||
return;
|
||||
}
|
||||
if (browserCommand === "Digit4") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimVisibleState(true);
|
||||
localStorage.setItem("stem-card-nvim-visible", "true");
|
||||
setNvimExpandedState(current => {
|
||||
const next = !current;
|
||||
if (next) setNvimFitState(false);
|
||||
return next;
|
||||
});
|
||||
setNvimExpanded(!nvimExpanded);
|
||||
return;
|
||||
}
|
||||
if (browserCommand === "Digit5") {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
setNvimVisibleState(true);
|
||||
localStorage.setItem("stem-card-nvim-visible", "true");
|
||||
setNvimFitState(current => {
|
||||
const next = !current;
|
||||
if (next) setNvimExpandedState(false);
|
||||
return next;
|
||||
});
|
||||
setNvimFit(!nvimFit);
|
||||
return;
|
||||
}
|
||||
if (event.key === "F12") {
|
||||
@@ -3012,51 +3298,30 @@ function App({ model }: { model: CardModel }) {
|
||||
setShortcutsOpen(false);
|
||||
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);
|
||||
return () => window.removeEventListener("keydown", navigate, true);
|
||||
}, [nvimSyncMode, shortcutsOpen]);
|
||||
}, [nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, shortcutsOpen]);
|
||||
useEffect(() => {
|
||||
const wheel = (event: WheelEvent) => {
|
||||
if (!event.altKey || event.ctrlKey) return;
|
||||
event.preventDefault();
|
||||
setScaleState((current) =>
|
||||
Math.max(
|
||||
setScaleState((current) => {
|
||||
const next = Math.max(
|
||||
0.25,
|
||||
Math.min(
|
||||
3,
|
||||
current *
|
||||
Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001),
|
||||
),
|
||||
),
|
||||
);
|
||||
void publishViewer({ scale: next });
|
||||
return next;
|
||||
});
|
||||
};
|
||||
window.addEventListener("wheel", wheel, { passive: false });
|
||||
return () => window.removeEventListener("wheel", wheel);
|
||||
}, []);
|
||||
}, [publishViewer]);
|
||||
const setStatus: SetProgressStatus = async (id, status) => {
|
||||
try {
|
||||
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
|
||||
@@ -3075,9 +3340,8 @@ function App({ model }: { model: CardModel }) {
|
||||
};
|
||||
const cycle = (id: string, current: Status) => {
|
||||
const next: Record<Status, Status> = {
|
||||
not_started: "discussed",
|
||||
discussed: "completed",
|
||||
completed: "not_started",
|
||||
pending: "approved",
|
||||
approved: "pending",
|
||||
};
|
||||
void setStatus(id, next[current]);
|
||||
};
|
||||
|
||||
+1
-5
@@ -1392,11 +1392,7 @@ text.uml-step-element-active {
|
||||
cursor: wait;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.lesson-progress-control[data-status="discussed"] {
|
||||
background: #fff4cc;
|
||||
border-color: #9b6a00;
|
||||
}
|
||||
.lesson-progress-control[data-status="completed"] {
|
||||
.lesson-progress-control[data-status="approved"] {
|
||||
background: #e7f6ed;
|
||||
border-color: #18794e;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user