feat: restore interactive Neovim card control
This commit is contained in:
+335
-124
@@ -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} />
|
||||
|
||||
+136
-5
@@ -8,6 +8,41 @@
|
||||
position: relative;
|
||||
top: auto;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen {
|
||||
position: fixed;
|
||||
z-index: 300;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #11171a;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .topbar {
|
||||
flex: none;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-panel {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-screen-scroll {
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
flex: 1;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-screen-content {
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.viewer-chrome.is-nvim-fullscreen .nvim-screen-content::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.topbar-nvim-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -53,7 +88,7 @@
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid #52616a;
|
||||
background: #11171a;
|
||||
background: var(--desk, #eee);
|
||||
color: #e7eef1;
|
||||
box-shadow: 0 5px 16px #0004;
|
||||
font: 12px/1.3 system-ui, sans-serif;
|
||||
@@ -96,12 +131,42 @@
|
||||
padding: 3px 8px;
|
||||
font: 700 10px/1.2 ui-monospace, monospace;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nvim-control-indicator.is-active {
|
||||
border-color: #3cb3e2;
|
||||
background: #17465a;
|
||||
color: #d9f5ff;
|
||||
}
|
||||
.nvim-control-indicator.is-engaged {
|
||||
box-shadow: inset 0 0 0 1px #8ee4ff;
|
||||
}
|
||||
.nvim-work-indicator {
|
||||
flex: none;
|
||||
border: 1px solid #7a878d;
|
||||
border-radius: 3px;
|
||||
background: #f4f6f7;
|
||||
color: #56646a;
|
||||
padding: 3px 8px;
|
||||
font: 700 10px/1.2 ui-monospace, monospace;
|
||||
letter-spacing: .03em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nvim-work-indicator.is-active {
|
||||
border-color: #cf3d31;
|
||||
background: #fff0ee;
|
||||
color: #9f241b;
|
||||
}
|
||||
.nvim-shortcut-strip {
|
||||
flex: none;
|
||||
color: #536168;
|
||||
font: 650 10px/1.2 ui-monospace, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nvim-shortcut-strip kbd {
|
||||
color: #263238;
|
||||
font: inherit;
|
||||
}
|
||||
.nvim-screen-scroll {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -109,15 +174,73 @@
|
||||
height: var(--nvim-panel-height, 75vh);
|
||||
max-height: none;
|
||||
min-height: 240px;
|
||||
border: 2px solid transparent;
|
||||
overflow: hidden;
|
||||
background: #101315;
|
||||
overflow: auto;
|
||||
background: var(--desk, #eee);
|
||||
outline: none;
|
||||
scrollbar-color: #58666d #171d20;
|
||||
}
|
||||
.nvim-screen-scroll[data-control="true"] {
|
||||
.nvim-screen-stage {
|
||||
position: relative;
|
||||
width: 210mm;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
zoom: var(--viewer-canvas-scale);
|
||||
}
|
||||
.nvim-screen-surface {
|
||||
position: absolute;
|
||||
inset: 0 4mm;
|
||||
box-sizing: border-box;
|
||||
padding: 1mm;
|
||||
background: #fff;
|
||||
}
|
||||
.nvim-screen-frame {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #267697;
|
||||
background: #101315;
|
||||
box-shadow: 0 0 0 3px #000;
|
||||
}
|
||||
.nvim-screen-scroll[data-sync="on"] .nvim-screen-frame {
|
||||
border-color: #35b9ef;
|
||||
}
|
||||
.nvim-screen-scroll[data-control="true"] .nvim-screen-frame {
|
||||
box-shadow: 0 0 0 3px #cf3d31;
|
||||
}
|
||||
.nvim-screen-scroll[data-control="true"] {
|
||||
cursor: text;
|
||||
}
|
||||
.nvim-screen-content {
|
||||
position: absolute;
|
||||
top: 1mm;
|
||||
right: -5mm;
|
||||
bottom: 1mm;
|
||||
left: 1mm;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
background: transparent;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #829097 #fff;
|
||||
}
|
||||
.nvim-screen-grid {
|
||||
width: calc(100% - 6mm);
|
||||
min-height: 100%;
|
||||
background: #101315;
|
||||
}
|
||||
.nvim-screen-content::-webkit-scrollbar {
|
||||
width: .8mm;
|
||||
}
|
||||
.nvim-screen-content::-webkit-scrollbar-track {
|
||||
background: #fff;
|
||||
}
|
||||
.nvim-screen-content::-webkit-scrollbar-thumb {
|
||||
border: .12mm solid #fff;
|
||||
border-radius: 99px;
|
||||
background: #829097;
|
||||
}
|
||||
.nvim-screen-content::-webkit-scrollbar-thumb:hover {
|
||||
background: #aeb9be;
|
||||
}
|
||||
.nvim-screen {
|
||||
display: block;
|
||||
max-width: none;
|
||||
@@ -1203,6 +1326,14 @@ text.uml-step-element-active {
|
||||
display: none;
|
||||
}
|
||||
.topbar-nvim-section .nvim-control-indicator {
|
||||
max-width: 42vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.nvim-shortcut-strip {
|
||||
display: none;
|
||||
}
|
||||
.nvim-work-indicator {
|
||||
display: none;
|
||||
}
|
||||
.memory-lanes {
|
||||
|
||||
Reference in New Issue
Block a user