diff --git a/react/src/main.tsx b/react/src/main.tsx index e59aada..774e22d 100644 --- a/react/src/main.tsx +++ b/react/src/main.tsx @@ -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"} {nvimVisible && ( <> @@ -294,8 +297,14 @@ function Topbar({ {nvimSummary.instance ?? "Neovim MCP"} {nvimSummary.detail && {nvimSummary.detail}} - - {nvimSummary.controlEnabled ? "STEROWANIE" : "PODGLĄD"} + + {nvimSummary.syncMode ? "F2 · SYNC ON" : "F2 · SYNC OFF"} + + + {nvimSummary.controlEnabled ? "STEROWANIE NVIM" : "NAJEDŹ · PRACUJ"} + + + F3 pełny · F4 odśwież · F12 skróty > )} @@ -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) { 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) { function NeovimPanel({ visible, + syncMode, + scale, onSummaryChange, }: { visible: boolean; + syncMode: boolean; + scale: number; onSummaryChange: (summary: NvimPanelSummary) => void; }) { const canvasRef = useRef(null); const screenRef = useRef(null); const websocketRef = useRef(null); + const redrawFrameRef = useRef(0); + const gridFlushRef = useRef(0); const gridRef = useRef(createNvimGrid()); + const targetEpochRef = useRef(null); const activeStepRef = useRef(null); const presentedSnapshotRef = useRef(null); const checkpointGenerationRef = useRef(0); @@ -787,17 +852,26 @@ function NeovimPanel({ const [status, setStatus] = useState({ state: "hidden" }); const [checkpoint, setCheckpoint] = useState({ 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) => [ + event.shiftKey ? "S" : "", + event.ctrlKey ? "C" : "", + event.altKey ? "A" : "", + ].filter(Boolean).join("-"); return ( { setPointerInside(true); event.currentTarget.focus({ preventScroll: true }); @@ -1108,27 +1222,64 @@ function NeovimPanel({ send({ type: "input", keys }); }} > - { - 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()} - /> + + + + + + { + 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()} + /> + + + + + {!hasGrid && ( {nvimStatusLabel[status.state]} @@ -1880,10 +2031,11 @@ function SnapshotInteractiveUmlSequence({ ) => { window.dispatchEvent(new CustomEvent("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 }) > - F1 · NAWIGACJA DYDAKTYCZNA + F12 · NAWIGACJA DYDAKTYCZNA TASK → BLOCK → PHASE → STEP → SNAPSHOT × @@ -2588,9 +2745,10 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) ))} - 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. @@ -2598,12 +2756,19 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) } function App({ model }: { model: CardModel }) { + const viewerChromeRef = useRef(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({ state: "hidden", + syncMode: false, + syncReady: false, controlEnabled: false, }); const [progress, setProgress] = useState(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 ( <> - + - + diff --git a/react/src/react.css b/react/src/react.css index 04a9c98..4178d1e 100644 --- a/react/src/react.css +++ b/react/src/react.css @@ -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 { diff --git a/schemas/card-source.schema.json b/schemas/card-source.schema.json index 731771a..062f669 100644 --- a/schemas/card-source.schema.json +++ b/schemas/card-source.schema.json @@ -867,7 +867,13 @@ "source_git_blob": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, "repository_revision": { "type": "string", "pattern": "^[0-9a-f]{40}$" }, "elf": { "type": "string", "minLength": 1 }, - "hazard3_elf_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + "hazard3_elf_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "hazard3_image": { "type": "string", "minLength": 1 }, + "hazard3_image_sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "dependentRequired": { + "hazard3_image": ["hazard3_image_sha256"], + "hazard3_image_sha256": ["hazard3_image"] }, "additionalProperties": false }, @@ -952,6 +958,15 @@ "type": "string", "minLength": 1 }, + "task": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "label": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, "block": { "type": "object", "required": ["id", "label"],
- 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.