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";
|
kind: "allocator-memory-flow" | "uml-sequence";
|
||||||
storage_key?: string;
|
storage_key?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
task?: { id: string; label: string };
|
||||||
block?: { id: string; label: string; description?: string };
|
block?: { id: string; label: string; description?: string };
|
||||||
snapshot_context?: {
|
snapshot_context?: {
|
||||||
target: string;
|
target: string;
|
||||||
@@ -198,6 +199,8 @@ type NvimPanelSummary = {
|
|||||||
message?: string;
|
message?: string;
|
||||||
instance?: string;
|
instance?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
syncMode: boolean;
|
||||||
|
syncReady: boolean;
|
||||||
controlEnabled: boolean;
|
controlEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,7 +286,7 @@ function Topbar({
|
|||||||
aria-pressed={nvimVisible}
|
aria-pressed={nvimVisible}
|
||||||
onClick={() => setNvimVisible(!nvimVisible)}
|
onClick={() => setNvimVisible(!nvimVisible)}
|
||||||
>
|
>
|
||||||
{nvimVisible ? "Ukryj Neovim" : "Pokaż Neovim"}
|
{nvimVisible ? "F1 · Ukryj Neovim" : "F1 · Pokaż Neovim"}
|
||||||
</button>
|
</button>
|
||||||
{nvimVisible && (
|
{nvimVisible && (
|
||||||
<>
|
<>
|
||||||
@@ -294,8 +297,14 @@ function Topbar({
|
|||||||
<strong>{nvimSummary.instance ?? "Neovim MCP"}</strong>
|
<strong>{nvimSummary.instance ?? "Neovim MCP"}</strong>
|
||||||
{nvimSummary.detail && <small>{nvimSummary.detail}</small>}
|
{nvimSummary.detail && <small>{nvimSummary.detail}</small>}
|
||||||
</span>
|
</span>
|
||||||
<span className={`nvim-control-indicator${nvimSummary.controlEnabled ? " is-active" : ""}`}>
|
<span className={`nvim-control-indicator${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}>
|
||||||
{nvimSummary.controlEnabled ? "STEROWANIE" : "PODGLĄD"}
|
{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>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -335,6 +344,7 @@ type NvimGridModel = {
|
|||||||
cursor: { row: number; column: number };
|
cursor: { row: number; column: number };
|
||||||
};
|
};
|
||||||
type ActiveNvimStep = {
|
type ActiveNvimStep = {
|
||||||
|
task: { id: string; label: string } | null;
|
||||||
block: { id?: string; label?: string } | null;
|
block: { id?: string; label?: string } | null;
|
||||||
phase: { id: string; label: string };
|
phase: { id: string; label: string };
|
||||||
step: UmlSequenceStep;
|
step: UmlSequenceStep;
|
||||||
@@ -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[]) {
|
function applyNvimRedraw(model: NvimGridModel, events: unknown[]) {
|
||||||
for (const rawEvent of events) {
|
for (const rawEvent of events) {
|
||||||
if (!Array.isArray(rawEvent) || typeof rawEvent[0] !== "string") continue;
|
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);
|
const [grid, width, height] = update.map(Number);
|
||||||
if (grid === 1) resizeNvimGrid(model, width, height);
|
if (grid === 1) resizeNvimGrid(model, width, height);
|
||||||
} else if (name === "default_colors_set") {
|
} else if (name === "default_colors_set") {
|
||||||
const foreground = xterm256(update[3]) ?? Number(update[0]);
|
const rgbForeground = Number(update[0]);
|
||||||
const background = xterm256(update[4]) ?? Number(update[1]);
|
const rgbBackground = Number(update[1]);
|
||||||
if (Number.isFinite(foreground) && foreground >= 0) model.foreground = foreground;
|
const foreground = rgbForeground >= 0
|
||||||
if (Number.isFinite(background) && background >= 0) model.background = background;
|
? 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") {
|
} else if (name === "hl_attr_define") {
|
||||||
const id = Number(update[0]);
|
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") {
|
if (Number.isInteger(id) && attributes && typeof attributes === "object") {
|
||||||
model.highlights.set(id, attributes as NvimHighlight);
|
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)}`;
|
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 content = line.map(cell => cell.text || " ").join("");
|
||||||
|
const matches: number[] = [];
|
||||||
let from = 0;
|
let from = 0;
|
||||||
let found = -1;
|
while (from < content.length) {
|
||||||
for (let index = 0; index < Math.max(1, occurrence); index += 1) {
|
const found = content.indexOf(text, from);
|
||||||
found = content.indexOf(text, from);
|
if (found < 0) break;
|
||||||
if (found < 0) return -1;
|
matches.push(found);
|
||||||
from = found + Math.max(1, text.length);
|
from = found + Math.max(1, text.length);
|
||||||
}
|
}
|
||||||
return found;
|
return matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function drawNvimGrid(
|
function paintNvimRows(
|
||||||
canvas: HTMLCanvasElement,
|
canvas: HTMLCanvasElement,
|
||||||
model: NvimGridModel,
|
model: NvimGridModel,
|
||||||
annotations: NvimAnnotation[],
|
startRow: number,
|
||||||
|
rowCount: number,
|
||||||
) {
|
) {
|
||||||
const availableWidth = canvas.parentElement?.clientWidth ?? model.width * 10;
|
const visibleRows = Math.max(1, Math.min(rowCount, model.height - startRow));
|
||||||
const cellWidth = Math.max(8, Math.min(12, availableWidth / model.width));
|
// The terminal grid is first composed on an unscaled A4 canvas. The same
|
||||||
const fontSize = Math.max(15, Math.min(19, cellWidth * 1.65));
|
// zoom as the paper is applied later by CSS to the completed frame. This
|
||||||
const cellHeight = Math.max(21, Math.min(25, cellWidth * 2.18));
|
// keeps all 190 columns stable instead of recalculating glyph metrics from
|
||||||
const ratio = Math.max(1, window.devicePixelRatio || 1);
|
// 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 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)) {
|
if (canvas.width !== Math.round(width * ratio) || canvas.height !== Math.round(height * ratio)) {
|
||||||
canvas.width = Math.round(width * ratio);
|
canvas.width = Math.round(width * ratio);
|
||||||
canvas.height = Math.round(height * ratio);
|
canvas.height = Math.round(height * ratio);
|
||||||
@@ -627,9 +667,9 @@ function drawNvimGrid(
|
|||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${height}px`;
|
||||||
}
|
}
|
||||||
canvas.dataset.columns = String(model.width);
|
canvas.dataset.columns = String(model.width);
|
||||||
canvas.dataset.rows = String(model.height);
|
canvas.dataset.rows = String(visibleRows);
|
||||||
const context = canvas.getContext("2d");
|
const context = canvas.getContext("2d");
|
||||||
if (!context) return;
|
if (!context) return null;
|
||||||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||||||
context.imageSmoothingEnabled = false;
|
context.imageSmoothingEnabled = false;
|
||||||
const defaultForeground = nvimColor(model.foreground, "#e8e8e8");
|
const defaultForeground = nvimColor(model.foreground, "#e8e8e8");
|
||||||
@@ -638,7 +678,8 @@ function drawNvimGrid(
|
|||||||
context.fillRect(0, 0, width, height);
|
context.fillRect(0, 0, width, height);
|
||||||
context.textBaseline = "alphabetic";
|
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) {
|
for (let column = 0; column < model.width; column += 1) {
|
||||||
const cell = model.cells[row][column];
|
const cell = model.cells[row][column];
|
||||||
const highlight = model.highlights.get(cell.highlight) ?? {};
|
const highlight = model.highlights.get(cell.highlight) ?? {};
|
||||||
@@ -647,17 +688,17 @@ function drawNvimGrid(
|
|||||||
if (highlight.reverse) [foreground, background] = [background, foreground];
|
if (highlight.reverse) [foreground, background] = [background, foreground];
|
||||||
if (background !== defaultBackground) {
|
if (background !== defaultBackground) {
|
||||||
context.fillStyle = background;
|
context.fillStyle = background;
|
||||||
context.fillRect(column * cellWidth, row * cellHeight, cellWidth, cellHeight);
|
context.fillRect(column * cellWidth, visibleRow * cellHeight, cellWidth, cellHeight);
|
||||||
}
|
}
|
||||||
if (cell.text && cell.text !== " ") {
|
if (cell.text && cell.text !== " ") {
|
||||||
context.fillStyle = foreground;
|
context.fillStyle = foreground;
|
||||||
context.font = `${highlight.italic ? "italic " : ""}${highlight.bold ? "700" : "500"} ${fontSize}px/1 ui-monospace, SFMono-Regular, Consolas, monospace`;
|
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) {
|
if (highlight.underline || highlight.undercurl || highlight.strikethrough) {
|
||||||
context.strokeStyle = nvimColor(highlight.special, foreground);
|
context.strokeStyle = nvimColor(highlight.special, foreground);
|
||||||
context.lineWidth = 1;
|
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.beginPath();
|
||||||
context.moveTo(column * cellWidth, y);
|
context.moveTo(column * cellWidth, y);
|
||||||
context.lineTo((column + 1) * cellWidth, y);
|
context.lineTo((column + 1) * cellWidth, y);
|
||||||
@@ -666,14 +707,27 @@ function drawNvimGrid(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
context.strokeStyle = "rgba(255,255,255,.72)";
|
if (model.cursor.row >= startRow && model.cursor.row < startRow + visibleRows) {
|
||||||
context.lineWidth = 1;
|
context.strokeStyle = "rgba(255,255,255,.72)";
|
||||||
context.strokeRect(
|
context.lineWidth = 1;
|
||||||
model.cursor.column * cellWidth + 0.5,
|
context.strokeRect(
|
||||||
model.cursor.row * cellHeight + 0.5,
|
model.cursor.column * cellWidth + 0.5,
|
||||||
cellWidth - 1,
|
(model.cursor.row - startRow) * cellHeight + 0.5,
|
||||||
cellHeight - 1,
|
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 = {
|
const toneColors = {
|
||||||
danger: "#ff3b30",
|
danger: "#ff3b30",
|
||||||
@@ -688,20 +742,22 @@ function drawNvimGrid(
|
|||||||
let rows = Math.max(1, Number(anchor.rows ?? 1));
|
let rows = Math.max(1, Number(anchor.rows ?? 1));
|
||||||
let columns = Math.max(1, Number(anchor.columns ?? 1));
|
let columns = Math.max(1, Number(anchor.columns ?? 1));
|
||||||
if (anchor.kind === "screen-text" && anchor.text) {
|
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) {
|
for (let candidate = 0; candidate < model.height; candidate += 1) {
|
||||||
const match = matchingTextColumn(
|
for (const match of matchingTextColumns(model.cells[candidate], anchor.text)) {
|
||||||
model.cells[candidate],
|
matches.push({ row: candidate, column: match });
|
||||||
anchor.text,
|
|
||||||
anchor.occurrence,
|
|
||||||
);
|
|
||||||
if (match >= 0) {
|
|
||||||
row = candidate;
|
|
||||||
column = match;
|
|
||||||
columns = anchor.text.length;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
if (row < 0 || column < 0 || row >= model.height || column >= model.width) continue;
|
||||||
const padding = Math.max(0, Number(anchor.padding_cells ?? 1));
|
const padding = Math.max(0, Number(anchor.padding_cells ?? 1));
|
||||||
@@ -750,9 +806,11 @@ function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
|
|||||||
Insert: "Insert",
|
Insert: "Insert",
|
||||||
};
|
};
|
||||||
const base = special[event.key] ?? (/^F\d{1,2}$/.test(event.key) ? event.key : null);
|
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" : ""]
|
const modifiers = [
|
||||||
.filter(Boolean)
|
event.ctrlKey ? "C" : "",
|
||||||
.join("-");
|
event.altKey ? "M" : "",
|
||||||
|
event.shiftKey && base ? "S" : "",
|
||||||
|
].filter(Boolean).join("-");
|
||||||
if (base) return `<${modifiers ? `${modifiers}-` : ""}${base}>`;
|
if (base) return `<${modifiers ? `${modifiers}-` : ""}${base}>`;
|
||||||
if (event.key.length !== 1) return null;
|
if (event.key.length !== 1) return null;
|
||||||
if (event.ctrlKey || event.altKey) {
|
if (event.ctrlKey || event.altKey) {
|
||||||
@@ -763,15 +821,22 @@ function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
|
|||||||
|
|
||||||
function NeovimPanel({
|
function NeovimPanel({
|
||||||
visible,
|
visible,
|
||||||
|
syncMode,
|
||||||
|
scale,
|
||||||
onSummaryChange,
|
onSummaryChange,
|
||||||
}: {
|
}: {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
syncMode: boolean;
|
||||||
|
scale: number;
|
||||||
onSummaryChange: (summary: NvimPanelSummary) => void;
|
onSummaryChange: (summary: NvimPanelSummary) => void;
|
||||||
}) {
|
}) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const screenRef = useRef<HTMLDivElement>(null);
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
const websocketRef = useRef<WebSocket | null>(null);
|
const websocketRef = useRef<WebSocket | null>(null);
|
||||||
|
const redrawFrameRef = useRef(0);
|
||||||
|
const gridFlushRef = useRef(0);
|
||||||
const gridRef = useRef<NvimGridModel>(createNvimGrid());
|
const gridRef = useRef<NvimGridModel>(createNvimGrid());
|
||||||
|
const targetEpochRef = useRef<string | null>(null);
|
||||||
const activeStepRef = useRef<ActiveNvimStep | null>(null);
|
const activeStepRef = useRef<ActiveNvimStep | null>(null);
|
||||||
const presentedSnapshotRef = useRef<string | null>(null);
|
const presentedSnapshotRef = useRef<string | null>(null);
|
||||||
const checkpointGenerationRef = useRef(0);
|
const checkpointGenerationRef = useRef(0);
|
||||||
@@ -787,17 +852,26 @@ function NeovimPanel({
|
|||||||
const [status, setStatus] = useState<NvimConnectionStatus>({ state: "hidden" });
|
const [status, setStatus] = useState<NvimConnectionStatus>({ state: "hidden" });
|
||||||
const [checkpoint, setCheckpoint] = useState<CheckpointStatus>({ state: "idle" });
|
const [checkpoint, setCheckpoint] = useState<CheckpointStatus>({ state: "idle" });
|
||||||
const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying";
|
const checkpointBusy = checkpoint.state === "replaying" || checkpoint.state === "verifying";
|
||||||
|
const syncReady = syncMode && status.state === "connected" && !checkpointBusy;
|
||||||
const controlEnabled = pointerInside && status.state === "connected" && !checkpointBusy;
|
const controlEnabled = pointerInside && status.state === "connected" && !checkpointBusy;
|
||||||
|
|
||||||
const redraw = () => {
|
const redraw = () => {
|
||||||
if (!canvasRef.current) return;
|
if (canvasRef.current) {
|
||||||
drawNvimGrid(
|
drawNvimGrid(
|
||||||
canvasRef.current,
|
canvasRef.current,
|
||||||
gridRef.current,
|
gridRef.current,
|
||||||
presentedSnapshotRef.current === activeStepRef.current?.step.snapshot_ref
|
presentedSnapshotRef.current === activeStepRef.current?.step.snapshot_ref
|
||||||
? activeStepRef.current?.step.view?.annotations ?? []
|
? activeStepRef.current?.step.view?.annotations ?? []
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleRedraw = () => {
|
||||||
|
if (redrawFrameRef.current) return;
|
||||||
|
redrawFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
redrawFrameRef.current = 0;
|
||||||
|
redraw();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -807,6 +881,22 @@ function NeovimPanel({
|
|||||||
setActiveStep(detail);
|
setActiveStep(detail);
|
||||||
presentedSnapshotRef.current = null;
|
presentedSnapshotRef.current = null;
|
||||||
const snapshotRef = detail.step.snapshot_ref;
|
const snapshotRef = detail.step.snapshot_ref;
|
||||||
|
void fetch("/api/navigation/current", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
task: detail.task,
|
||||||
|
block: detail.block,
|
||||||
|
phase: detail.phase,
|
||||||
|
step: {
|
||||||
|
id: detail.step.id,
|
||||||
|
number: detail.step.number,
|
||||||
|
label: detail.step.label,
|
||||||
|
},
|
||||||
|
snapshot_ref: snapshotRef ?? null,
|
||||||
|
sync_requested: Boolean(detail.activate),
|
||||||
|
}),
|
||||||
|
}).catch(() => undefined);
|
||||||
if (status.state !== "connected" && snapshotRef) {
|
if (status.state !== "connected" && snapshotRef) {
|
||||||
void loadRecordedNvimGrid(detail.step).then(saved => {
|
void loadRecordedNvimGrid(detail.step).then(saved => {
|
||||||
if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
|
if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
|
||||||
@@ -885,6 +975,24 @@ function NeovimPanel({
|
|||||||
if (!response.ok || result.status !== "ready") {
|
if (!response.ok || result.status !== "ready") {
|
||||||
throw new Error(result.message ?? `Replay zakończył się kodem HTTP ${response.status}.`);
|
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;
|
presentedSnapshotRef.current = snapshotRef;
|
||||||
setCheckpoint({
|
setCheckpoint({
|
||||||
state: "ready",
|
state: "ready",
|
||||||
@@ -893,11 +1001,8 @@ function NeovimPanel({
|
|||||||
snapshot_ref: snapshotRef,
|
snapshot_ref: snapshotRef,
|
||||||
message: result.message ?? "Stan maszyny został odtworzony i zweryfikowany.",
|
message: result.message ?? "Stan maszyny został odtworzony i zweryfikowany.",
|
||||||
});
|
});
|
||||||
window.setTimeout(() => {
|
saveNvimGrid(snapshotRef, gridRef.current);
|
||||||
if (checkpointGenerationRef.current !== generation) return;
|
redraw();
|
||||||
saveNvimGrid(snapshotRef, gridRef.current);
|
|
||||||
redraw();
|
|
||||||
}, 180);
|
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
if (controller.signal.aborted || checkpointGenerationRef.current !== generation) return;
|
if (controller.signal.aborted || checkpointGenerationRef.current !== generation) return;
|
||||||
@@ -932,36 +1037,20 @@ function NeovimPanel({
|
|||||||
}, [panelHeightVh]);
|
}, [panelHeightVh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
redraw();
|
||||||
let lastSize = "";
|
}, [scale]);
|
||||||
let lastSentAt = 0;
|
|
||||||
let lastSocket: WebSocket | null = null;
|
useEffect(() => {
|
||||||
const requestResize = () => {
|
const refresh = () => {
|
||||||
const screen = screenRef.current;
|
|
||||||
const socket = websocketRef.current;
|
const socket = websocketRef.current;
|
||||||
if (!screen || socket?.readyState !== WebSocket.OPEN) return;
|
if (socket?.readyState === WebSocket.OPEN) {
|
||||||
if (socket !== lastSocket) {
|
socket.send(JSON.stringify({ type: "refresh" }));
|
||||||
lastSocket = socket;
|
|
||||||
lastSize = "";
|
|
||||||
}
|
}
|
||||||
const columns = Math.max(40, Math.min(400, Math.floor(screen.clientWidth / 10)));
|
redraw();
|
||||||
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 }));
|
|
||||||
};
|
};
|
||||||
const observer = new ResizeObserver(requestResize);
|
window.addEventListener("stem:nvim-refresh", refresh);
|
||||||
if (screenRef.current) observer.observe(screenRef.current);
|
return () => window.removeEventListener("stem:nvim-refresh", refresh);
|
||||||
const retry = window.setInterval(requestResize, 500);
|
}, []);
|
||||||
requestResize();
|
|
||||||
return () => {
|
|
||||||
observer.disconnect();
|
|
||||||
window.clearInterval(retry);
|
|
||||||
};
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let summaryState: NvimPanelSummary["state"] = status.state;
|
let summaryState: NvimPanelSummary["state"] = status.state;
|
||||||
@@ -979,9 +1068,11 @@ function NeovimPanel({
|
|||||||
: undefined,
|
: undefined,
|
||||||
checkpoint.phase,
|
checkpoint.phase,
|
||||||
].filter(Boolean).join(" · "),
|
].filter(Boolean).join(" · "),
|
||||||
|
syncMode,
|
||||||
|
syncReady,
|
||||||
controlEnabled,
|
controlEnabled,
|
||||||
});
|
});
|
||||||
}, [activeStep, checkpoint, controlEnabled, onSummaryChange, status]);
|
}, [activeStep, checkpoint, controlEnabled, onSummaryChange, status, syncMode, syncReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!controlEnabled) return;
|
if (!controlEnabled) return;
|
||||||
@@ -1031,13 +1122,28 @@ function NeovimPanel({
|
|||||||
try {
|
try {
|
||||||
const message = JSON.parse(String(event.data));
|
const message = JSON.parse(String(event.data));
|
||||||
if (message.type === "status") {
|
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);
|
setStatus(message as NvimConnectionStatus);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "redraw" && Array.isArray(message.events)) {
|
if (message.type === "redraw" && Array.isArray(message.events)) {
|
||||||
applyNvimRedraw(gridRef.current, message.events);
|
applyNvimRedraw(gridRef.current, message.events);
|
||||||
if (gridRef.current.width > 1 && gridRef.current.height > 1) setHasGrid(true);
|
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 {
|
} catch {
|
||||||
// Ignore malformed local bridge frames and keep the last good grid.
|
// Ignore malformed local bridge frames and keep the last good grid.
|
||||||
@@ -1059,6 +1165,8 @@ function NeovimPanel({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(reconnectTimer);
|
window.clearTimeout(reconnectTimer);
|
||||||
|
window.cancelAnimationFrame(redrawFrameRef.current);
|
||||||
|
redrawFrameRef.current = 0;
|
||||||
websocketRef.current?.close();
|
websocketRef.current?.close();
|
||||||
websocketRef.current = null;
|
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 (
|
return (
|
||||||
<section className="nvim-panel" aria-label="Neovim połączony z kontenerem">
|
<section className="nvim-panel" aria-label="Neovim połączony z kontenerem">
|
||||||
<div
|
<div
|
||||||
ref={screenRef}
|
ref={screenRef}
|
||||||
className="nvim-screen-scroll"
|
className="nvim-screen-scroll"
|
||||||
style={{ height: `${panelHeightVh}vh` }}
|
style={{ height: `${panelHeightVh}vh` }}
|
||||||
tabIndex={0}
|
data-sync={syncMode ? "on" : "off"}
|
||||||
data-control={controlEnabled ? "true" : "false"}
|
data-control={controlEnabled ? "true" : "false"}
|
||||||
|
tabIndex={0}
|
||||||
onPointerEnter={event => {
|
onPointerEnter={event => {
|
||||||
setPointerInside(true);
|
setPointerInside(true);
|
||||||
event.currentTarget.focus({ preventScroll: true });
|
event.currentTarget.focus({ preventScroll: true });
|
||||||
@@ -1108,27 +1222,64 @@ function NeovimPanel({
|
|||||||
send({ type: "input", keys });
|
send({ type: "input", keys });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<canvas
|
<div
|
||||||
ref={canvasRef}
|
className="nvim-screen-stage"
|
||||||
className="nvim-screen"
|
style={{ height: `${panelHeightVh / Math.max(scale, 0.25)}vh` }}
|
||||||
tabIndex={-1}
|
>
|
||||||
aria-label={controlEnabled ? "Interaktywny ekran Neovima" : "Podgląd ekranu Neovima"}
|
<div className="nvim-screen-surface">
|
||||||
onMouseDown={event => {
|
<div className="nvim-screen-frame">
|
||||||
if (!controlEnabled || status.state !== "connected") return;
|
<div className="nvim-screen-content">
|
||||||
event.preventDefault();
|
<div className="nvim-screen-grid">
|
||||||
const point = locateMouse(event);
|
<canvas
|
||||||
const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
|
ref={canvasRef}
|
||||||
send({ type: "mouse", button, action: "press", modifier: "", ...point });
|
className="nvim-screen"
|
||||||
}}
|
tabIndex={-1}
|
||||||
onMouseUp={event => {
|
aria-label={controlEnabled ? "Interaktywny ekran Neovima" : "Ekran Neovima"}
|
||||||
if (!controlEnabled || status.state !== "connected") return;
|
onMouseDown={event => {
|
||||||
event.preventDefault();
|
if (!controlEnabled || status.state !== "connected") return;
|
||||||
const point = locateMouse(event);
|
event.preventDefault();
|
||||||
const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
|
const point = locateMouse(event);
|
||||||
send({ type: "mouse", button, action: "release", modifier: "", ...point });
|
const button = event.button === 1 ? "middle" : event.button === 2 ? "right" : "left";
|
||||||
}}
|
send({
|
||||||
onContextMenu={event => controlEnabled && event.preventDefault()}
|
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 && (
|
{!hasGrid && (
|
||||||
<div className="nvim-screen-empty">
|
<div className="nvim-screen-empty">
|
||||||
<strong>{nvimStatusLabel[status.state]}</strong>
|
<strong>{nvimStatusLabel[status.state]}</strong>
|
||||||
@@ -1880,10 +2031,11 @@ function SnapshotInteractiveUmlSequence({
|
|||||||
) => {
|
) => {
|
||||||
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", {
|
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", {
|
||||||
detail: {
|
detail: {
|
||||||
|
task: config.task ?? null,
|
||||||
block: config.block ?? null,
|
block: config.block ?? null,
|
||||||
phase: { id: entry.phase.id, label: entry.phase.label },
|
phase: { id: entry.phase.id, label: entry.phase.label },
|
||||||
step: entry.step,
|
step: entry.step,
|
||||||
activate,
|
activate: activate && document.documentElement.dataset.nvimSyncMode === "on",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -1949,7 +2101,6 @@ function SnapshotInteractiveUmlSequence({
|
|||||||
if (event.metaKey) return;
|
if (event.metaKey) return;
|
||||||
const target = event.target as HTMLElement | null;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
||||||
if (document.activeElement?.closest(".nvim-screen-scroll[data-control='true']")) return;
|
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
if (!root) return;
|
if (!root) return;
|
||||||
const diagrams = Array.from(
|
const diagrams = Array.from(
|
||||||
@@ -2560,7 +2711,13 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
|
|||||||
["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"],
|
["Ctrl + Shift + ← / →", "poprzedni / następny unikalny SNAPSHOT"],
|
||||||
["Alt + ← / →", "poprzedni / następny BLOCK"],
|
["Alt + ← / →", "poprzedni / następny BLOCK"],
|
||||||
["Ctrl + ← / →", "poprzedni / następny TASK"],
|
["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"],
|
["Esc", "zamknij skorowidz"],
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
@@ -2574,7 +2731,7 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
|
|||||||
>
|
>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<small>F1 · NAWIGACJA DYDAKTYCZNA</small>
|
<small>F12 · NAWIGACJA DYDAKTYCZNA</small>
|
||||||
<h2 id="shortcut-help-title">TASK → BLOCK → PHASE → STEP → SNAPSHOT</h2>
|
<h2 id="shortcut-help-title">TASK → BLOCK → PHASE → STEP → SNAPSHOT</h2>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={onClose} aria-label="Zamknij skorowidz">×</button>
|
<button onClick={onClose} aria-label="Zamknij skorowidz">×</button>
|
||||||
@@ -2588,9 +2745,10 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
|
|||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
<p>
|
<p>
|
||||||
STEP wybiera strzałkę UML. ONLINE odtwarza przypięty stan maszyny;
|
Przy SYNC ON wybór stepu odtwarza i weryfikuje przypięty stan maszyny.
|
||||||
OFFLINE pokazuje jego zapisany grid. W aktywnym panelu Neovima skróty
|
SYNC OFF nie resetuje debuggera. Po najechaniu panel przekazuje do
|
||||||
klawiszowe — poza F1 — trafiają do Neovima.
|
Neovima wyłącznie kontrolowane zdarzenia klawiatury i myszy, więc można
|
||||||
|
normalnie kontynuować debugowanie.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -2598,12 +2756,19 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App({ model }: { model: CardModel }) {
|
function App({ model }: { model: CardModel }) {
|
||||||
|
const viewerChromeRef = useRef<HTMLDivElement>(null);
|
||||||
const [scale, setScaleState] = useState(2.18);
|
const [scale, setScaleState] = useState(2.18);
|
||||||
const [nvimVisible, setNvimVisibleState] = useState(
|
const [nvimVisible, setNvimVisibleState] = useState(
|
||||||
() => localStorage.getItem("stem-card-nvim-visible") !== "false",
|
() => localStorage.getItem("stem-card-nvim-visible") !== "false",
|
||||||
);
|
);
|
||||||
|
const [nvimSyncMode, setNvimSyncModeState] = useState(
|
||||||
|
() => localStorage.getItem("stem-card-nvim-sync-mode") === "true",
|
||||||
|
);
|
||||||
|
const [nvimFullscreen, setNvimFullscreen] = useState(false);
|
||||||
const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
|
const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
|
||||||
state: "hidden",
|
state: "hidden",
|
||||||
|
syncMode: false,
|
||||||
|
syncReady: false,
|
||||||
controlEnabled: false,
|
controlEnabled: false,
|
||||||
});
|
});
|
||||||
const [progress, setProgress] = useState<Progress | null>(null);
|
const [progress, setProgress] = useState<Progress | null>(null);
|
||||||
@@ -2617,6 +2782,9 @@ function App({ model }: { model: CardModel }) {
|
|||||||
scale.toFixed(3),
|
scale.toFixed(3),
|
||||||
);
|
);
|
||||||
}, [scale]);
|
}, [scale]);
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.nvimSyncMode = nvimSyncMode ? "on" : "off";
|
||||||
|
}, [nvimSyncMode]);
|
||||||
const setNvimVisible = (value: boolean) => {
|
const setNvimVisible = (value: boolean) => {
|
||||||
setNvimVisibleState(value);
|
setNvimVisibleState(value);
|
||||||
localStorage.setItem("stem-card-nvim-visible", String(value));
|
localStorage.setItem("stem-card-nvim-visible", String(value));
|
||||||
@@ -2637,7 +2805,42 @@ function App({ model }: { model: CardModel }) {
|
|||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const navigate = (event: KeyboardEvent) => {
|
const navigate = (event: KeyboardEvent) => {
|
||||||
|
if (event.repeat) return;
|
||||||
if (event.key === "F1") {
|
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.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
setShortcutsOpen(current => !current);
|
setShortcutsOpen(current => !current);
|
||||||
@@ -2648,8 +2851,8 @@ function App({ model }: { model: CardModel }) {
|
|||||||
setShortcutsOpen(false);
|
setShortcutsOpen(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
||||||
if (document.activeElement?.closest(".nvim-screen-scroll[data-control='true']")) 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;
|
const target = event.target as HTMLElement | null;
|
||||||
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
||||||
const isTask = event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
|
const isTask = event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey;
|
||||||
@@ -2720,7 +2923,10 @@ function App({ model }: { model: CardModel }) {
|
|||||||
let figureCount = 0;
|
let figureCount = 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="viewer-chrome">
|
<div
|
||||||
|
ref={viewerChromeRef}
|
||||||
|
className={`viewer-chrome${nvimFullscreen ? " is-nvim-fullscreen" : ""}`}
|
||||||
|
>
|
||||||
<Topbar
|
<Topbar
|
||||||
toc={model.toc}
|
toc={model.toc}
|
||||||
scale={scale}
|
scale={scale}
|
||||||
@@ -2729,7 +2935,12 @@ function App({ model }: { model: CardModel }) {
|
|||||||
setNvimVisible={setNvimVisible}
|
setNvimVisible={setNvimVisible}
|
||||||
nvimSummary={nvimSummary}
|
nvimSummary={nvimSummary}
|
||||||
/>
|
/>
|
||||||
<NeovimPanel visible={nvimVisible} onSummaryChange={setNvimSummary} />
|
<NeovimPanel
|
||||||
|
visible={nvimVisible}
|
||||||
|
syncMode={nvimSyncMode}
|
||||||
|
scale={scale}
|
||||||
|
onSummaryChange={setNvimSummary}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<main className="paper">
|
<main className="paper">
|
||||||
<FrontPage front={model.front} />
|
<FrontPage front={model.front} />
|
||||||
|
|||||||
+136
-5
@@ -8,6 +8,41 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: auto;
|
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 {
|
.topbar-nvim-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -53,7 +88,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-bottom: 1px solid #52616a;
|
border-bottom: 1px solid #52616a;
|
||||||
background: #11171a;
|
background: var(--desk, #eee);
|
||||||
color: #e7eef1;
|
color: #e7eef1;
|
||||||
box-shadow: 0 5px 16px #0004;
|
box-shadow: 0 5px 16px #0004;
|
||||||
font: 12px/1.3 system-ui, sans-serif;
|
font: 12px/1.3 system-ui, sans-serif;
|
||||||
@@ -96,12 +131,42 @@
|
|||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
font: 700 10px/1.2 ui-monospace, monospace;
|
font: 700 10px/1.2 ui-monospace, monospace;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.nvim-control-indicator.is-active {
|
.nvim-control-indicator.is-active {
|
||||||
border-color: #3cb3e2;
|
border-color: #3cb3e2;
|
||||||
background: #17465a;
|
background: #17465a;
|
||||||
color: #d9f5ff;
|
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 {
|
.nvim-screen-scroll {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -109,15 +174,73 @@
|
|||||||
height: var(--nvim-panel-height, 75vh);
|
height: var(--nvim-panel-height, 75vh);
|
||||||
max-height: none;
|
max-height: none;
|
||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
border: 2px solid transparent;
|
overflow: auto;
|
||||||
overflow: hidden;
|
background: var(--desk, #eee);
|
||||||
background: #101315;
|
|
||||||
outline: none;
|
outline: none;
|
||||||
scrollbar-color: #58666d #171d20;
|
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;
|
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 {
|
.nvim-screen {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -1203,6 +1326,14 @@ text.uml-step-element-active {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.topbar-nvim-section .nvim-control-indicator {
|
.topbar-nvim-section .nvim-control-indicator {
|
||||||
|
max-width: 42vw;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.nvim-shortcut-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nvim-work-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.memory-lanes {
|
.memory-lanes {
|
||||||
|
|||||||
@@ -867,7 +867,13 @@
|
|||||||
"source_git_blob": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
"source_git_blob": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
||||||
"repository_revision": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
"repository_revision": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
|
||||||
"elf": { "type": "string", "minLength": 1 },
|
"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
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
@@ -952,6 +958,15 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 1
|
"minLength": 1
|
||||||
},
|
},
|
||||||
|
"task": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "label"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "type": "string", "minLength": 1 },
|
||||||
|
"label": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"block": {
|
"block": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["id", "label"],
|
"required": ["id", "label"],
|
||||||
|
|||||||
Reference in New Issue
Block a user