feat: restore interactive Neovim card control

This commit is contained in:
user
2026-07-16 23:59:03 +02:00
parent fc357bc190
commit 5255839e27
3 changed files with 487 additions and 130 deletions
+335 -124
View File
@@ -110,6 +110,7 @@ type InteractiveAsset = {
kind: "allocator-memory-flow" | "uml-sequence";
storage_key?: string;
title?: string;
task?: { id: string; label: string };
block?: { id: string; label: string; description?: string };
snapshot_context?: {
target: string;
@@ -198,6 +199,8 @@ type NvimPanelSummary = {
message?: string;
instance?: string;
detail?: string;
syncMode: boolean;
syncReady: boolean;
controlEnabled: boolean;
};
@@ -283,7 +286,7 @@ function Topbar({
aria-pressed={nvimVisible}
onClick={() => setNvimVisible(!nvimVisible)}
>
{nvimVisible ? "Ukryj Neovim" : "Pokaż Neovim"}
{nvimVisible ? "F1 · Ukryj Neovim" : "F1 · Pokaż Neovim"}
</button>
{nvimVisible && (
<>
@@ -294,8 +297,14 @@ function Topbar({
<strong>{nvimSummary.instance ?? "Neovim MCP"}</strong>
{nvimSummary.detail && <small>{nvimSummary.detail}</small>}
</span>
<span className={`nvim-control-indicator${nvimSummary.controlEnabled ? " is-active" : ""}`}>
{nvimSummary.controlEnabled ? "STEROWANIE" : "PODGLĄD"}
<span className={`nvim-control-indicator${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}>
{nvimSummary.syncMode ? "F2 · SYNC ON" : "F2 · SYNC OFF"}
</span>
<span className={`nvim-work-indicator${nvimSummary.controlEnabled ? " is-active" : ""}`}>
{nvimSummary.controlEnabled ? "STEROWANIE NVIM" : "NAJEDŹ · PRACUJ"}
</span>
<span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima">
<kbd>F3</kbd> pełny · <kbd>F4</kbd> odśwież · <kbd>F12</kbd> skróty
</span>
</>
)}
@@ -335,6 +344,7 @@ type NvimGridModel = {
cursor: { row: number; column: number };
};
type ActiveNvimStep = {
task: { id: string; label: string } | null;
block: { id?: string; label?: string } | null;
phase: { id: string; label: string };
step: UmlSequenceStep;
@@ -524,6 +534,12 @@ function ctermHighlight(attributes: unknown): NvimHighlight | null {
};
}
function rgbHighlight(attributes: unknown): NvimHighlight | null {
if (!attributes || typeof attributes !== "object") return null;
const source = attributes as NvimHighlight;
return Object.keys(source).length ? { ...source } : null;
}
function applyNvimRedraw(model: NvimGridModel, events: unknown[]) {
for (const rawEvent of events) {
if (!Array.isArray(rawEvent) || typeof rawEvent[0] !== "string") continue;
@@ -535,13 +551,26 @@ function applyNvimRedraw(model: NvimGridModel, events: unknown[]) {
const [grid, width, height] = update.map(Number);
if (grid === 1) resizeNvimGrid(model, width, height);
} else if (name === "default_colors_set") {
const foreground = xterm256(update[3]) ?? Number(update[0]);
const background = xterm256(update[4]) ?? Number(update[1]);
if (Number.isFinite(foreground) && foreground >= 0) model.foreground = foreground;
if (Number.isFinite(background) && background >= 0) model.background = background;
const rgbForeground = Number(update[0]);
const rgbBackground = Number(update[1]);
const foreground = rgbForeground >= 0
? rgbForeground
: xterm256(update[3]);
const background = rgbBackground >= 0
? rgbBackground
: xterm256(update[4]);
if (typeof foreground === "number" && Number.isFinite(foreground) && foreground >= 0) {
model.foreground = foreground;
}
if (typeof background === "number" && Number.isFinite(background) && background >= 0) {
model.background = background;
}
} else if (name === "hl_attr_define") {
const id = Number(update[0]);
const attributes = ctermHighlight(update[2]) ?? update[1];
// The selected terminal runs with 'notermguicolors', so its visible
// palette comes from cterm attributes. RGB remains a fallback for a
// future true-colour session.
const attributes = ctermHighlight(update[2]) ?? rgbHighlight(update[1]);
if (Number.isInteger(id) && attributes && typeof attributes === "object") {
model.highlights.set(id, attributes as NvimHighlight);
}
@@ -596,30 +625,41 @@ function nvimColor(value: number | undefined, fallback: string) {
return `#${Number(value).toString(16).padStart(6, "0").slice(-6)}`;
}
function matchingTextColumn(line: NvimCell[], text: string, occurrence = 1) {
function matchingTextColumns(line: NvimCell[], text: string) {
const content = line.map(cell => cell.text || " ").join("");
const matches: number[] = [];
let from = 0;
let found = -1;
for (let index = 0; index < Math.max(1, occurrence); index += 1) {
found = content.indexOf(text, from);
if (found < 0) return -1;
while (from < content.length) {
const found = content.indexOf(text, from);
if (found < 0) break;
matches.push(found);
from = found + Math.max(1, text.length);
}
return found;
return matches;
}
function drawNvimGrid(
function paintNvimRows(
canvas: HTMLCanvasElement,
model: NvimGridModel,
annotations: NvimAnnotation[],
startRow: number,
rowCount: number,
) {
const availableWidth = canvas.parentElement?.clientWidth ?? model.width * 10;
const cellWidth = Math.max(8, Math.min(12, availableWidth / model.width));
const fontSize = Math.max(15, Math.min(19, cellWidth * 1.65));
const cellHeight = Math.max(21, Math.min(25, cellWidth * 2.18));
const ratio = Math.max(1, window.devicePixelRatio || 1);
const visibleRows = Math.max(1, Math.min(rowCount, model.height - startRow));
// The terminal grid is first composed on an unscaled A4 canvas. The same
// zoom as the paper is applied later by CSS to the completed frame. This
// keeps all 190 columns stable instead of recalculating glyph metrics from
// the already-zoomed browser viewport.
const baseWidth = canvas.parentElement?.clientWidth ?? (210 * 96) / 25.4;
const cellWidth = baseWidth / model.width;
const cellHeight = cellWidth * 1.9;
const fontSize = cellWidth * 1.62;
const viewerScale = Number.parseFloat(
getComputedStyle(document.documentElement)
.getPropertyValue("--viewer-canvas-scale"),
) || 1;
const ratio = Math.max(1, window.devicePixelRatio || 1) * viewerScale;
const width = model.width * cellWidth;
const height = model.height * cellHeight;
const height = visibleRows * cellHeight;
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
@@ -627,9 +667,9 @@ function drawNvimGrid(
canvas.style.height = `${height}px`;
}
canvas.dataset.columns = String(model.width);
canvas.dataset.rows = String(model.height);
canvas.dataset.rows = String(visibleRows);
const context = canvas.getContext("2d");
if (!context) return;
if (!context) return null;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
context.imageSmoothingEnabled = false;
const defaultForeground = nvimColor(model.foreground, "#e8e8e8");
@@ -638,7 +678,8 @@ function drawNvimGrid(
context.fillRect(0, 0, width, height);
context.textBaseline = "alphabetic";
for (let row = 0; row < model.height; row += 1) {
for (let visibleRow = 0; visibleRow < visibleRows; visibleRow += 1) {
const row = startRow + visibleRow;
for (let column = 0; column < model.width; column += 1) {
const cell = model.cells[row][column];
const highlight = model.highlights.get(cell.highlight) ?? {};
@@ -647,17 +688,17 @@ function drawNvimGrid(
if (highlight.reverse) [foreground, background] = [background, foreground];
if (background !== defaultBackground) {
context.fillStyle = background;
context.fillRect(column * cellWidth, row * cellHeight, cellWidth, cellHeight);
context.fillRect(column * cellWidth, visibleRow * cellHeight, cellWidth, cellHeight);
}
if (cell.text && cell.text !== " ") {
context.fillStyle = foreground;
context.font = `${highlight.italic ? "italic " : ""}${highlight.bold ? "700" : "500"} ${fontSize}px/1 ui-monospace, SFMono-Regular, Consolas, monospace`;
context.fillText(cell.text, column * cellWidth, row * cellHeight + cellHeight * 0.76);
context.fillText(cell.text, column * cellWidth, visibleRow * cellHeight + cellHeight * 0.76);
}
if (highlight.underline || highlight.undercurl || highlight.strikethrough) {
context.strokeStyle = nvimColor(highlight.special, foreground);
context.lineWidth = 1;
const y = row * cellHeight + (highlight.strikethrough ? cellHeight * 0.5 : cellHeight - 2);
const y = visibleRow * cellHeight + (highlight.strikethrough ? cellHeight * 0.5 : cellHeight - 2);
context.beginPath();
context.moveTo(column * cellWidth, y);
context.lineTo((column + 1) * cellWidth, y);
@@ -666,14 +707,27 @@ function drawNvimGrid(
}
}
context.strokeStyle = "rgba(255,255,255,.72)";
context.lineWidth = 1;
context.strokeRect(
model.cursor.column * cellWidth + 0.5,
model.cursor.row * cellHeight + 0.5,
cellWidth - 1,
cellHeight - 1,
);
if (model.cursor.row >= startRow && model.cursor.row < startRow + visibleRows) {
context.strokeStyle = "rgba(255,255,255,.72)";
context.lineWidth = 1;
context.strokeRect(
model.cursor.column * cellWidth + 0.5,
(model.cursor.row - startRow) * cellHeight + 0.5,
cellWidth - 1,
cellHeight - 1,
);
}
return { context, cellWidth, cellHeight };
}
function drawNvimGrid(
canvas: HTMLCanvasElement,
model: NvimGridModel,
annotations: NvimAnnotation[],
) {
const painted = paintNvimRows(canvas, model, 0, model.height);
if (!painted) return;
const { context, cellWidth, cellHeight } = painted;
const toneColors = {
danger: "#ff3b30",
@@ -688,20 +742,22 @@ function drawNvimGrid(
let rows = Math.max(1, Number(anchor.rows ?? 1));
let columns = Math.max(1, Number(anchor.columns ?? 1));
if (anchor.kind === "screen-text" && anchor.text) {
row = -1;
const matches: Array<{ row: number; column: number }> = [];
for (let candidate = 0; candidate < model.height; candidate += 1) {
const match = matchingTextColumn(
model.cells[candidate],
anchor.text,
anchor.occurrence,
);
if (match >= 0) {
row = candidate;
column = match;
columns = anchor.text.length;
break;
for (const match of matchingTextColumns(model.cells[candidate], anchor.text)) {
matches.push({ row: candidate, column: match });
}
}
const semanticArea = annotation.anchor_ref?.split(".")[0];
if (semanticArea === "src") {
matches.sort((left, right) => right.column - left.column || left.row - right.row);
} else if (["asm", "reg", "memory"].includes(semanticArea ?? "")) {
matches.sort((left, right) => left.column - right.column || left.row - right.row);
}
const selected = matches[Math.max(0, Number(anchor.occurrence ?? 1) - 1)];
row = selected?.row ?? -1;
column = selected?.column ?? -1;
columns = anchor.text.length;
}
if (row < 0 || column < 0 || row >= model.height || column >= model.width) continue;
const padding = Math.max(0, Number(anchor.padding_cells ?? 1));
@@ -750,9 +806,11 @@ function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
Insert: "Insert",
};
const base = special[event.key] ?? (/^F\d{1,2}$/.test(event.key) ? event.key : null);
const modifiers = [event.ctrlKey ? "C" : "", event.altKey ? "M" : "", event.shiftKey && base ? "S" : ""]
.filter(Boolean)
.join("-");
const modifiers = [
event.ctrlKey ? "C" : "",
event.altKey ? "M" : "",
event.shiftKey && base ? "S" : "",
].filter(Boolean).join("-");
if (base) return `<${modifiers ? `${modifiers}-` : ""}${base}>`;
if (event.key.length !== 1) return null;
if (event.ctrlKey || event.altKey) {
@@ -763,15 +821,22 @@ function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
function NeovimPanel({
visible,
syncMode,
scale,
onSummaryChange,
}: {
visible: boolean;
syncMode: boolean;
scale: number;
onSummaryChange: (summary: NvimPanelSummary) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const screenRef = useRef<HTMLDivElement>(null);
const websocketRef = useRef<WebSocket | null>(null);
const redrawFrameRef = useRef(0);
const gridFlushRef = useRef(0);
const gridRef = useRef<NvimGridModel>(createNvimGrid());
const targetEpochRef = useRef<string | null>(null);
const activeStepRef = useRef<ActiveNvimStep | null>(null);
const presentedSnapshotRef = useRef<string | null>(null);
const checkpointGenerationRef = useRef(0);
@@ -787,17 +852,26 @@ function NeovimPanel({
const [status, setStatus] = useState<NvimConnectionStatus>({ state: "hidden" });
const [checkpoint, setCheckpoint] = useState<CheckpointStatus>({ state: "idle" });
const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying";
const syncReady = syncMode && status.state === "connected" && !checkpointBusy;
const controlEnabled = pointerInside && status.state === "connected" && !checkpointBusy;
const redraw = () => {
if (!canvasRef.current) return;
drawNvimGrid(
canvasRef.current,
gridRef.current,
presentedSnapshotRef.current === activeStepRef.current?.step.snapshot_ref
? activeStepRef.current?.step.view?.annotations ?? []
: [],
);
if (canvasRef.current) {
drawNvimGrid(
canvasRef.current,
gridRef.current,
presentedSnapshotRef.current === activeStepRef.current?.step.snapshot_ref
? activeStepRef.current?.step.view?.annotations ?? []
: [],
);
}
};
const scheduleRedraw = () => {
if (redrawFrameRef.current) return;
redrawFrameRef.current = window.requestAnimationFrame(() => {
redrawFrameRef.current = 0;
redraw();
});
};
useEffect(() => {
@@ -807,6 +881,22 @@ 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;
@@ -885,6 +975,24 @@ function NeovimPanel({
if (!response.ok || result.status !== "ready") {
throw new Error(result.message ?? `Replay zakończył się kodem HTTP ${response.status}.`);
}
const socket = websocketRef.current;
if (socket?.readyState !== WebSocket.OPEN) {
throw new Error("Replay zakończony, ale most Neovima jest offline.");
}
const flushBeforeRefresh = gridFlushRef.current;
socket.send(JSON.stringify({ type: "refresh" }));
const redrawDeadline = Date.now() + 1800;
while (
gridFlushRef.current <= flushBeforeRefresh
&& Date.now() < redrawDeadline
&& checkpointGenerationRef.current === generation
) {
await new Promise(resolve => window.setTimeout(resolve, 20));
}
if (gridFlushRef.current <= flushBeforeRefresh) {
throw new Error("Neovim nie potwierdził pełnego redraw po replay.");
}
if (checkpointGenerationRef.current !== generation) return;
presentedSnapshotRef.current = snapshotRef;
setCheckpoint({
state: "ready",
@@ -893,11 +1001,8 @@ function NeovimPanel({
snapshot_ref: snapshotRef,
message: result.message ?? "Stan maszyny został odtworzony i zweryfikowany.",
});
window.setTimeout(() => {
if (checkpointGenerationRef.current !== generation) return;
saveNvimGrid(snapshotRef, gridRef.current);
redraw();
}, 180);
saveNvimGrid(snapshotRef, gridRef.current);
redraw();
})
.catch(error => {
if (controller.signal.aborted || checkpointGenerationRef.current !== generation) return;
@@ -932,36 +1037,20 @@ function NeovimPanel({
}, [panelHeightVh]);
useEffect(() => {
if (!visible) return;
let lastSize = "";
let lastSentAt = 0;
let lastSocket: WebSocket | null = null;
const requestResize = () => {
const screen = screenRef.current;
redraw();
}, [scale]);
useEffect(() => {
const refresh = () => {
const socket = websocketRef.current;
if (!screen || socket?.readyState !== WebSocket.OPEN) return;
if (socket !== lastSocket) {
lastSocket = socket;
lastSize = "";
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "refresh" }));
}
const columns = Math.max(40, Math.min(400, Math.floor(screen.clientWidth / 10)));
const rows = Math.max(8, Math.min(200, Math.floor(screen.clientHeight / 22)));
const size = `${columns}x${rows}`;
const now = performance.now();
if (size === lastSize && now - lastSentAt < 2000) return;
lastSize = size;
lastSentAt = now;
socket.send(JSON.stringify({ type: "resize", columns, rows }));
redraw();
};
const observer = new ResizeObserver(requestResize);
if (screenRef.current) observer.observe(screenRef.current);
const retry = window.setInterval(requestResize, 500);
requestResize();
return () => {
observer.disconnect();
window.clearInterval(retry);
};
}, [visible]);
window.addEventListener("stem:nvim-refresh", refresh);
return () => window.removeEventListener("stem:nvim-refresh", refresh);
}, []);
useEffect(() => {
let summaryState: NvimPanelSummary["state"] = status.state;
@@ -979,9 +1068,11 @@ function NeovimPanel({
: undefined,
checkpoint.phase,
].filter(Boolean).join(" · "),
syncMode,
syncReady,
controlEnabled,
});
}, [activeStep, checkpoint, controlEnabled, onSummaryChange, status]);
}, [activeStep, checkpoint, controlEnabled, onSummaryChange, status, syncMode, syncReady]);
useEffect(() => {
if (!controlEnabled) return;
@@ -1031,13 +1122,28 @@ function NeovimPanel({
try {
const message = JSON.parse(String(event.data));
if (message.type === "status") {
if (
typeof message.epoch === "string"
&& targetEpochRef.current
&& targetEpochRef.current !== message.epoch
) {
gridRef.current = createNvimGrid();
setHasGrid(false);
presentedSnapshotRef.current = null;
}
if (typeof message.epoch === "string") targetEpochRef.current = message.epoch;
setStatus(message as NvimConnectionStatus);
return;
}
if (message.type === "redraw" && Array.isArray(message.events)) {
applyNvimRedraw(gridRef.current, message.events);
if (gridRef.current.width > 1 && gridRef.current.height > 1) setHasGrid(true);
redraw();
if (message.events.some(
(event: unknown) => Array.isArray(event) && event[0] === "flush",
)) {
gridFlushRef.current += 1;
scheduleRedraw();
}
}
} catch {
// Ignore malformed local bridge frames and keep the last good grid.
@@ -1059,6 +1165,8 @@ function NeovimPanel({
return () => {
cancelled = true;
window.clearTimeout(reconnectTimer);
window.cancelAnimationFrame(redrawFrameRef.current);
redrawFrameRef.current = 0;
websocketRef.current?.close();
websocketRef.current = null;
};
@@ -1082,14 +1190,20 @@ function NeovimPanel({
)),
};
};
const mouseModifier = (event: React.MouseEvent<HTMLCanvasElement>) => [
event.shiftKey ? "S" : "",
event.ctrlKey ? "C" : "",
event.altKey ? "A" : "",
].filter(Boolean).join("-");
return (
<section className="nvim-panel" aria-label="Neovim połączony z kontenerem">
<div
ref={screenRef}
className="nvim-screen-scroll"
style={{ height: `${panelHeightVh}vh` }}
tabIndex={0}
data-sync={syncMode ? "on" : "off"}
data-control={controlEnabled ? "true" : "false"}
tabIndex={0}
onPointerEnter={event => {
setPointerInside(true);
event.currentTarget.focus({ preventScroll: true });
@@ -1108,27 +1222,64 @@ function NeovimPanel({
send({ type: "input", keys });
}}
>
<canvas
ref={canvasRef}
className="nvim-screen"
tabIndex={-1}
aria-label={controlEnabled ? "Interaktywny ekran Neovima" : "Podgląd ekranu Neovima"}
onMouseDown={event => {
if (!controlEnabled || status.state !== "connected") return;
event.preventDefault();
const point = locateMouse(event);
const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
send({ type: "mouse", button, action: "press", modifier: "", ...point });
}}
onMouseUp={event => {
if (!controlEnabled || status.state !== "connected") return;
event.preventDefault();
const point = locateMouse(event);
const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
send({ type: "mouse", button, action: "release", modifier: "", ...point });
}}
onContextMenu={event => controlEnabled && event.preventDefault()}
/>
<div
className="nvim-screen-stage"
style={{ height: `${panelHeightVh / Math.max(scale, 0.25)}vh` }}
>
<div className="nvim-screen-surface">
<div className="nvim-screen-frame">
<div className="nvim-screen-content">
<div className="nvim-screen-grid">
<canvas
ref={canvasRef}
className="nvim-screen"
tabIndex={-1}
aria-label={controlEnabled ? "Interaktywny ekran Neovima" : "Ekran Neovima"}
onMouseDown={event => {
if (!controlEnabled || status.state !== "connected") return;
event.preventDefault();
const point = locateMouse(event);
const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
send({
type: "mouse",
button,
action: "press",
modifier: mouseModifier(event),
...point,
});
}}
onMouseUp={event => {
if (!controlEnabled || status.state !== "connected") return;
event.preventDefault();
const point = locateMouse(event);
const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
send({
type: "mouse",
button,
action: "release",
modifier: mouseModifier(event),
...point,
});
}}
onWheel={event => {
if (!controlEnabled || status.state !== "connected") return;
event.preventDefault();
const point = locateMouse(event);
send({
type: "mouse",
button: "wheel",
action: event.deltaY < 0 ? "up" : "down",
modifier: mouseModifier(event),
...point,
});
}}
onContextMenu={event => controlEnabled && event.preventDefault()}
/>
</div>
</div>
</div>
</div>
</div>
{!hasGrid && (
<div className="nvim-screen-empty">
<strong>{nvimStatusLabel[status.state]}</strong>
@@ -1880,10 +2031,11 @@ function SnapshotInteractiveUmlSequence({
) => {
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", {
detail: {
task: config.task ?? null,
block: config.block ?? null,
phase: { id: entry.phase.id, label: entry.phase.label },
step: entry.step,
activate,
activate: activate && document.documentElement.dataset.nvimSyncMode === "on",
},
}));
};
@@ -1949,7 +2101,6 @@ function SnapshotInteractiveUmlSequence({
if (event.metaKey) return;
const target = event.target as HTMLElement | null;
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
if (document.activeElement?.closest(".nvim-screen-scroll[data-control='true']")) return;
const root = rootRef.current;
if (!root) return;
const diagrams = Array.from(
@@ -2560,7 +2711,13 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"],
["Alt + ← / →", "poprzedni / następny BLOCK"],
["Ctrl + ← / →", "poprzedni / następny TASK"],
["F1", "pokaż lub ukryj ten skorowidz"],
["F1", "pokaż lub ukryj panel Neovima"],
["F2", "przełącz SYNC OFF / SYNC ON"],
["F3", "włącz lub wyłącz pełny ekran Neovima bez suwaka"],
["F4", "odśwież layout i ekran Neovima"],
["Najedź na Neovim", "przekaż klawiaturę i mysz do aktywnej sesji"],
["Zatwierdź krok", "zapisz bieżący step i timestamp do raportu"],
["F12", "pokaż lub ukryj ten skorowidz"],
["Esc", "zamknij skorowidz"],
];
return (
@@ -2574,7 +2731,7 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
>
<header>
<div>
<small>F1 · NAWIGACJA DYDAKTYCZNA</small>
<small>F12 · NAWIGACJA DYDAKTYCZNA</small>
<h2 id="shortcut-help-title">TASK BLOCK PHASE STEP SNAPSHOT</h2>
</div>
<button onClick={onClose} aria-label="Zamknij skorowidz">×</button>
@@ -2588,9 +2745,10 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
))}
</dl>
<p>
STEP wybiera strzałkę UML. ONLINE odtwarza przypięty stan maszyny;
OFFLINE pokazuje jego zapisany grid. W aktywnym panelu Neovima skróty
klawiszowe poza F1 trafiają do Neovima.
Przy SYNC ON wybór stepu odtwarza i weryfikuje przypięty stan maszyny.
SYNC OFF nie resetuje debuggera. Po najechaniu panel przekazuje do
Neovima wyłącznie kontrolowane zdarzenia klawiatury i myszy, więc można
normalnie kontynuować debugowanie.
</p>
</section>
</div>
@@ -2598,12 +2756,19 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
}
function App({ model }: { model: CardModel }) {
const viewerChromeRef = useRef<HTMLDivElement>(null);
const [scale, setScaleState] = useState(2.18);
const [nvimVisible, setNvimVisibleState] = useState(
() => localStorage.getItem("stem-card-nvim-visible") !== "false",
);
const [nvimSyncMode, setNvimSyncModeState] = useState(
() => localStorage.getItem("stem-card-nvim-sync-mode") === "true",
);
const [nvimFullscreen, setNvimFullscreen] = useState(false);
const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
state: "hidden",
syncMode: false,
syncReady: false,
controlEnabled: false,
});
const [progress, setProgress] = useState<Progress | null>(null);
@@ -2617,6 +2782,9 @@ function App({ model }: { model: CardModel }) {
scale.toFixed(3),
);
}, [scale]);
useEffect(() => {
document.documentElement.dataset.nvimSyncMode = nvimSyncMode ? "on" : "off";
}, [nvimSyncMode]);
const setNvimVisible = (value: boolean) => {
setNvimVisibleState(value);
localStorage.setItem("stem-card-nvim-visible", String(value));
@@ -2637,7 +2805,42 @@ function App({ model }: { model: CardModel }) {
}, []);
useEffect(() => {
const navigate = (event: KeyboardEvent) => {
if (event.repeat) return;
if (event.key === "F1") {
event.preventDefault();
event.stopImmediatePropagation();
setNvimVisibleState(current => {
const next = !current;
localStorage.setItem("stem-card-nvim-visible", String(next));
return next;
});
return;
}
if (event.key === "F2") {
event.preventDefault();
event.stopImmediatePropagation();
setNvimSyncModeState(current => {
const next = !current;
localStorage.setItem("stem-card-nvim-sync-mode", String(next));
return next;
});
return;
}
if (event.key === "F3") {
event.preventDefault();
event.stopImmediatePropagation();
setNvimVisibleState(true);
localStorage.setItem("stem-card-nvim-visible", "true");
setNvimFullscreen(current => !current);
return;
}
if (event.key === "F4") {
event.preventDefault();
event.stopImmediatePropagation();
window.dispatchEvent(new Event("stem:nvim-refresh"));
return;
}
if (event.key === "F12") {
event.preventDefault();
event.stopImmediatePropagation();
setShortcutsOpen(current => !current);
@@ -2648,8 +2851,8 @@ function App({ model }: { model: CardModel }) {
setShortcutsOpen(false);
return;
}
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") 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;
@@ -2720,7 +2923,10 @@ function App({ model }: { model: CardModel }) {
let figureCount = 0;
return (
<>
<div className="viewer-chrome">
<div
ref={viewerChromeRef}
className={`viewer-chrome${nvimFullscreen ? " is-nvim-fullscreen" : ""}`}
>
<Topbar
toc={model.toc}
scale={scale}
@@ -2729,7 +2935,12 @@ function App({ model }: { model: CardModel }) {
setNvimVisible={setNvimVisible}
nvimSummary={nvimSummary}
/>
<NeovimPanel visible={nvimVisible} onSummaryChange={setNvimSummary} />
<NeovimPanel
visible={nvimVisible}
syncMode={nvimSyncMode}
scale={scale}
onSummaryChange={setNvimSummary}
/>
</div>
<main className="paper">
<FrontPage front={model.front} />