3709 lines
129 KiB
TypeScript
3709 lines
129 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||
import { createRoot } from "react-dom/client";
|
||
import "./react.css";
|
||
|
||
type Status = "pending" | "approved";
|
||
type NavigationLevel = "card" | "task" | "block" | "phase" | "step" | "snapshot";
|
||
type NavigationPosition = {
|
||
task_index: number;
|
||
block_index: number;
|
||
phase_index: number;
|
||
step_index: number;
|
||
global_step_index: number;
|
||
snapshot_index: number | null;
|
||
global_snapshot_index: number | null;
|
||
};
|
||
type ApiNavigation = {
|
||
revision: number;
|
||
actor?: string;
|
||
focus_level: NavigationLevel;
|
||
task: { id: string; label: string; index: number };
|
||
block: { id: string; label: string; index: number };
|
||
phase: { id: string; label: string; index: number };
|
||
step: { id: string; label: string; number: number; index: number; global_index: number };
|
||
snapshot_ref: string | null;
|
||
position: NavigationPosition;
|
||
sync_requested: boolean;
|
||
};
|
||
type ApiViewerState = {
|
||
revision: number;
|
||
actor?: string | null;
|
||
scale: number;
|
||
scroll: {
|
||
x: number;
|
||
y: number;
|
||
page_index: number;
|
||
sheet_scroll_top?: number;
|
||
};
|
||
viewport: { width: number; height: number };
|
||
allocator?: { visible: boolean };
|
||
nvim: {
|
||
visible: boolean;
|
||
sync: boolean;
|
||
control: boolean;
|
||
expanded: boolean;
|
||
fit: boolean;
|
||
connection: string;
|
||
screen_cursor: { row: number; column: number } | null;
|
||
grid: { columns: number; rows: number } | null;
|
||
};
|
||
};
|
||
type InteractiveArea = "compile" | "bss" | "stack" | "registers" | "text";
|
||
type InteractiveSymbol = {
|
||
id: string;
|
||
area: InteractiveArea;
|
||
declaration: string;
|
||
description: string;
|
||
};
|
||
type UmlStage = {
|
||
id: string;
|
||
label: string;
|
||
description: string;
|
||
code_ref?: string;
|
||
progress_id?: string;
|
||
svg_label?: string;
|
||
};
|
||
type UmlSnapshotRegister = { name: string; value: string; note?: string };
|
||
type UmlSnapshotFrameField = {
|
||
name: string;
|
||
address: string;
|
||
value: string;
|
||
size_bytes?: number;
|
||
state: "initialised" | "uninitialised" | "saved";
|
||
note?: string;
|
||
};
|
||
type NvimAnnotation = {
|
||
id: string;
|
||
anchor_ref?: string;
|
||
label?: string;
|
||
tone?: "danger" | "info" | "success" | "warning";
|
||
shape?: "outline" | "underline";
|
||
anchor: {
|
||
kind: "screen-text" | "grid-range";
|
||
text?: string;
|
||
occurrence?: number;
|
||
row?: number;
|
||
column?: number;
|
||
rows?: number;
|
||
columns?: number;
|
||
padding_cells?: number;
|
||
};
|
||
};
|
||
type NvimStepView = {
|
||
connection_ref?: string;
|
||
layout?: string;
|
||
recorded_grid_ref?: string;
|
||
reveal?: {
|
||
anchor_ref?: string;
|
||
align?: "top" | "center" | "bottom";
|
||
};
|
||
annotations?: NvimAnnotation[];
|
||
};
|
||
type UmlSnapshot = {
|
||
target?: string;
|
||
captured_at?: string;
|
||
captured_with?: string;
|
||
elf?: string;
|
||
pc: string;
|
||
registers: UmlSnapshotRegister[];
|
||
frame: {
|
||
name: string;
|
||
sp: string;
|
||
fp: string;
|
||
stack_top?: string;
|
||
size_bytes: number;
|
||
fields: UmlSnapshotFrameField[];
|
||
};
|
||
memory: {
|
||
items: { name: string; address: string; value: string; note?: string }[];
|
||
arena?: {
|
||
base: string;
|
||
size_bytes: number;
|
||
cursor: string;
|
||
offset: number | null;
|
||
bytes: string;
|
||
};
|
||
};
|
||
code?: {
|
||
source: string;
|
||
source_ref?: string;
|
||
assembly: string;
|
||
note?: string;
|
||
};
|
||
description: string;
|
||
};
|
||
type UmlSequenceStep = {
|
||
id: string;
|
||
number: number;
|
||
label: string;
|
||
description: string;
|
||
code_ref?: string;
|
||
progress_id?: string;
|
||
svg_label?: string;
|
||
snapshot_ref?: string;
|
||
view?: NvimStepView;
|
||
snapshot: UmlSnapshot;
|
||
};
|
||
type UmlSequencePhase = {
|
||
id: string;
|
||
label: string;
|
||
description?: string;
|
||
svg_label?: string;
|
||
steps: UmlSequenceStep[];
|
||
};
|
||
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;
|
||
captured_at: string;
|
||
captured_with?: string;
|
||
elf?: string;
|
||
stack_top?: string;
|
||
};
|
||
terminal_view?: {
|
||
path?: string;
|
||
href?: string;
|
||
title?: string;
|
||
alt: string;
|
||
caption?: string;
|
||
};
|
||
arena_size?: number;
|
||
allocations?: number[];
|
||
symbols?: InteractiveSymbol[];
|
||
stages?: UmlStage[];
|
||
phases?: UmlSequencePhase[];
|
||
};
|
||
type Asset = {
|
||
label: string;
|
||
caption: string;
|
||
alt: string;
|
||
href: string;
|
||
width: number;
|
||
kind: string;
|
||
source_href?: string;
|
||
interactive?: InteractiveAsset | null;
|
||
};
|
||
type FlowStep = { id: string; title: string; content_html: string };
|
||
type FlowBlock = {
|
||
id: string;
|
||
kind: "block" | "exercise";
|
||
title: string;
|
||
content_html: string;
|
||
steps: FlowStep[];
|
||
};
|
||
type Task = {
|
||
ref: string;
|
||
title: string;
|
||
uuid?: string;
|
||
criterion: string;
|
||
conclusion_html: string;
|
||
prompt_html?: string;
|
||
flow: FlowBlock[];
|
||
};
|
||
type Section = {
|
||
id: string;
|
||
index: number;
|
||
title: string;
|
||
show_heading: boolean;
|
||
header_html: string;
|
||
left_margin_html: string;
|
||
right_margin_html: string;
|
||
content_html: string;
|
||
tasks: Task[];
|
||
assets: Asset[];
|
||
steps: { id: string; title: string }[];
|
||
};
|
||
type Progress = {
|
||
items: Record<string, { status: Status }>;
|
||
summary: { total: number; counts: Record<Status, number> };
|
||
};
|
||
type SetProgressStatus = (id: string, status: Status) => Promise<void>;
|
||
type CardModel = {
|
||
schema: string;
|
||
card: Record<string, string>;
|
||
total_pages: number;
|
||
front: any;
|
||
sections: Section[];
|
||
dictionary: { header_html: string; content_html: string } | null;
|
||
toc: { id: string; label: string }[];
|
||
};
|
||
type NvimPanelSummary = {
|
||
state:
|
||
| "hidden"
|
||
| "connecting"
|
||
| "connected"
|
||
| "replaying"
|
||
| "verifying"
|
||
| "ready"
|
||
| "failed"
|
||
| "offline"
|
||
| "unselected";
|
||
message?: string;
|
||
instance?: string;
|
||
detail?: string;
|
||
syncMode: boolean;
|
||
syncReady: boolean;
|
||
controlMode: boolean;
|
||
controlEnabled: boolean;
|
||
screenCursor?: { row: number; column: number };
|
||
grid?: { columns: number; rows: number };
|
||
};
|
||
|
||
const nvimStatusLabel: Record<NvimPanelSummary["state"], string> = {
|
||
hidden: "UKRYTY",
|
||
connecting: "ŁĄCZENIE",
|
||
connected: "ONLINE · LIVE",
|
||
replaying: "ONLINE · REPLAY",
|
||
verifying: "ONLINE · WERYFIKACJA",
|
||
ready: "ONLINE · GOTOWY",
|
||
failed: "ONLINE · BŁĄD STANU",
|
||
offline: "OFFLINE · ZAPIS",
|
||
unselected: "OFFLINE · BRAK CELU",
|
||
};
|
||
|
||
const UML_SCROLLOFF_RATIO = 0.25;
|
||
const UML_SCROLLOFF_EPSILON_PX = 1;
|
||
const UML_SCROLLOFF_MAX_PASSES = 4;
|
||
|
||
type VerticalViewport = { top: number; bottom: number };
|
||
|
||
function clipsVertically(element: HTMLElement) {
|
||
const overflow = window.getComputedStyle(element).overflowY;
|
||
return overflow === "auto" || overflow === "scroll" || overflow === "hidden"
|
||
|| overflow === "clip";
|
||
}
|
||
|
||
function elementClientViewport(element: HTMLElement): VerticalViewport {
|
||
const bounds = element.getBoundingClientRect();
|
||
const layoutHeight = element.offsetHeight || element.clientHeight;
|
||
const scale = layoutHeight > 0 ? bounds.height / layoutHeight : 1;
|
||
const top = bounds.top + element.clientTop * scale;
|
||
return {
|
||
top,
|
||
bottom: top + element.clientHeight * scale,
|
||
};
|
||
}
|
||
|
||
function chromeAwareWindowViewport(target: Element): VerticalViewport | null {
|
||
const chrome = document.querySelector<HTMLElement>(".viewer-chrome");
|
||
if (chrome?.classList.contains("is-nvim-fit")) return null;
|
||
|
||
let top = 0;
|
||
let bottom = window.innerHeight;
|
||
if (chrome) {
|
||
const chromeBounds = chrome.getBoundingClientRect();
|
||
const targetBounds = target.getBoundingClientRect();
|
||
const overlapsHorizontally = chromeBounds.left < targetBounds.right
|
||
&& chromeBounds.right > targetBounds.left;
|
||
if (
|
||
overlapsHorizontally
|
||
&& chromeBounds.bottom > 0
|
||
&& chromeBounds.top < window.innerHeight
|
||
) {
|
||
top = Math.max(top, chromeBounds.bottom);
|
||
}
|
||
}
|
||
return bottom - top > UML_SCROLLOFF_EPSILON_PX ? { top, bottom } : null;
|
||
}
|
||
|
||
function visibleVerticalViewport(target: Element): VerticalViewport | null {
|
||
const windowViewport = chromeAwareWindowViewport(target);
|
||
if (!windowViewport) return null;
|
||
|
||
let { top, bottom } = windowViewport;
|
||
for (
|
||
let ancestor = target.parentElement;
|
||
ancestor && ancestor !== document.body && ancestor !== document.documentElement;
|
||
ancestor = ancestor.parentElement
|
||
) {
|
||
if (!clipsVertically(ancestor)) continue;
|
||
const client = elementClientViewport(ancestor);
|
||
top = Math.max(top, client.top);
|
||
bottom = Math.min(bottom, client.bottom);
|
||
}
|
||
return bottom - top > UML_SCROLLOFF_EPSILON_PX ? { top, bottom } : null;
|
||
}
|
||
|
||
function documentScroller() {
|
||
return (document.scrollingElement as HTMLElement | null)
|
||
?? document.documentElement;
|
||
}
|
||
|
||
function verticalScrollChain(target: Element) {
|
||
const chain: HTMLElement[] = [];
|
||
for (
|
||
let ancestor = target.parentElement;
|
||
ancestor && ancestor !== document.body && ancestor !== document.documentElement;
|
||
ancestor = ancestor.parentElement
|
||
) {
|
||
const overflow = window.getComputedStyle(ancestor).overflowY;
|
||
const acceptsScroll = overflow === "auto" || overflow === "scroll";
|
||
if (acceptsScroll && ancestor.scrollHeight - ancestor.clientHeight > 1) {
|
||
chain.push(ancestor);
|
||
}
|
||
}
|
||
const root = documentScroller();
|
||
if (!chain.includes(root)) chain.push(root);
|
||
return chain;
|
||
}
|
||
|
||
function visualScrollScale(scroller: HTMLElement) {
|
||
if (scroller === documentScroller()) return 1;
|
||
const bounds = scroller.getBoundingClientRect();
|
||
const layoutHeight = scroller.offsetHeight || scroller.clientHeight;
|
||
const scale = layoutHeight > 0 ? bounds.height / layoutHeight : 1;
|
||
return Number.isFinite(scale) && scale > 0 ? scale : 1;
|
||
}
|
||
|
||
function setScrollPositionInstant(
|
||
scroller: HTMLElement,
|
||
position: { top?: number; left?: number },
|
||
) {
|
||
const previousBehavior = scroller.style.scrollBehavior;
|
||
scroller.style.scrollBehavior = "auto";
|
||
if (position.left != null) scroller.scrollLeft = position.left;
|
||
if (position.top != null) scroller.scrollTop = position.top;
|
||
scroller.style.scrollBehavior = previousBehavior;
|
||
}
|
||
|
||
function bringTargetSheetIntoWindow(target: Element, viewport: VerticalViewport) {
|
||
const anchor = target.closest<HTMLElement>(".sheet-content");
|
||
if (!anchor) return 0;
|
||
const bounds = elementClientViewport(anchor);
|
||
let delta = 0;
|
||
if (bounds.bottom <= viewport.top) {
|
||
delta = bounds.bottom - viewport.bottom;
|
||
} else if (bounds.top >= viewport.bottom) {
|
||
delta = bounds.top - viewport.top;
|
||
}
|
||
if (Math.abs(delta) <= UML_SCROLLOFF_EPSILON_PX) return 0;
|
||
|
||
const root = documentScroller();
|
||
const previous = root.scrollTop;
|
||
const maximum = Math.max(0, root.scrollHeight - root.clientHeight);
|
||
setScrollPositionInstant(root, {
|
||
top: Math.max(0, Math.min(maximum, previous + delta)),
|
||
});
|
||
return root.scrollTop - previous;
|
||
}
|
||
|
||
function applyVerticalScrollDelta(target: Element, delta: number) {
|
||
let remaining = delta;
|
||
for (const scroller of verticalScrollChain(target)) {
|
||
if (Math.abs(remaining) <= UML_SCROLLOFF_EPSILON_PX) break;
|
||
const scale = visualScrollScale(scroller);
|
||
const maximum = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||
const previous = scroller.scrollTop;
|
||
const next = Math.max(0, Math.min(maximum, previous + remaining / scale));
|
||
setScrollPositionInstant(scroller, { top: next });
|
||
remaining -= (scroller.scrollTop - previous) * scale;
|
||
}
|
||
return delta - remaining;
|
||
}
|
||
|
||
function umlScrolloffDelta(target: Element, viewport: VerticalViewport) {
|
||
const bounds = target.getBoundingClientRect();
|
||
const cursor = (bounds.top + bounds.bottom) / 2;
|
||
const height = viewport.bottom - viewport.top;
|
||
const upper = viewport.top + height * UML_SCROLLOFF_RATIO;
|
||
const lower = viewport.bottom - height * UML_SCROLLOFF_RATIO;
|
||
if (cursor < upper) return cursor - upper;
|
||
if (cursor > lower) return cursor - lower;
|
||
return 0;
|
||
}
|
||
|
||
const Html = ({ html, className }: { html: string; className?: string }) => (
|
||
<div className={className} dangerouslySetInnerHTML={{ __html: html }} />
|
||
);
|
||
|
||
function Header({ html }: { html: string }) {
|
||
return <Html html={html} />;
|
||
}
|
||
|
||
function Margin({ side, html }: { side: "left" | "right"; html: string }) {
|
||
return (
|
||
<aside
|
||
className={`pdf-margin ${side}`}
|
||
dangerouslySetInnerHTML={{ __html: html }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function Sheet({
|
||
header,
|
||
left = "",
|
||
right = "",
|
||
children,
|
||
className = "",
|
||
}: React.PropsWithChildren<any>) {
|
||
return (
|
||
<article className={`sheet ${className}`}>
|
||
<div className="sheet-content">
|
||
<Header html={header} />
|
||
<Margin side="left" html={left} />
|
||
<section className="pdf-main card-section">{children}</section>
|
||
<Margin side="right" html={right} />
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function Topbar({
|
||
toc,
|
||
scale,
|
||
setScale,
|
||
nvimVisible,
|
||
setNvimVisible,
|
||
nvimSyncMode,
|
||
setNvimSyncMode,
|
||
nvimControlMode,
|
||
setNvimControlMode,
|
||
nvimExpanded,
|
||
setNvimExpanded,
|
||
nvimFit,
|
||
setNvimFit,
|
||
allocatorVisible,
|
||
setAllocatorVisible,
|
||
nvimSummary,
|
||
}: {
|
||
toc: CardModel["toc"];
|
||
scale: number;
|
||
setScale: (value: number) => void;
|
||
nvimVisible: boolean;
|
||
setNvimVisible: (value: boolean) => void;
|
||
nvimSyncMode: boolean;
|
||
setNvimSyncMode: (value: boolean) => void;
|
||
nvimControlMode: boolean;
|
||
setNvimControlMode: (value: boolean) => void;
|
||
nvimExpanded: boolean;
|
||
setNvimExpanded: (value: boolean) => void;
|
||
nvimFit: boolean;
|
||
setNvimFit: (value: boolean) => void;
|
||
allocatorVisible: boolean;
|
||
setAllocatorVisible: (value: boolean) => void;
|
||
nvimSummary: NvimPanelSummary;
|
||
}) {
|
||
return (
|
||
<nav className="topbar">
|
||
<div className="topbar-inner">
|
||
<details>
|
||
<summary>Spis treści</summary>
|
||
<div className="toc-links">
|
||
{toc.map((item) => (
|
||
<a key={item.id} href={`#${item.id}`}>
|
||
{item.label}
|
||
</a>
|
||
))}
|
||
</div>
|
||
</details>
|
||
<div className="topbar-nvim-section" aria-label="Panel Neovima">
|
||
<span className="nvim-toolbar-boundary" aria-hidden="true">|</span>
|
||
<span className="nvim-toolbar-context">NVIM</span>
|
||
<div className="nvim-toolbar-actions" aria-label="Narzędzia okna Neovima">
|
||
<button
|
||
className="viewer-nvim-toggle nvim-toolbar-button"
|
||
aria-label={nvimVisible ? "Alt+1: ukryj Neovim" : "Alt+1: pokaż Neovim"}
|
||
title={nvimVisible ? "Alt+1 · Ukryj Neovim" : "Alt+1 · Pokaż Neovim"}
|
||
aria-pressed={nvimVisible}
|
||
onClick={() => setNvimVisible(!nvimVisible)}
|
||
>
|
||
<kbd className="nvim-toolbar-key">A1</kbd><span className="nvim-toolbar-label">PANEL</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`nvim-control-indicator nvim-toolbar-button${nvimSummary.syncMode ? " is-active" : ""}${nvimSummary.syncReady ? " is-engaged" : ""}`}
|
||
aria-label={`Alt+2: synchronizacja ${nvimSyncMode ? "włączona" : "wyłączona"}`}
|
||
title={`Alt+2 · SYNC ${nvimSyncMode ? "ON" : "OFF"}`}
|
||
aria-pressed={nvimSyncMode}
|
||
onClick={() => setNvimSyncMode(!nvimSyncMode)}
|
||
>
|
||
<kbd className="nvim-toolbar-key">A2</kbd><span className="nvim-toolbar-label">SYNC</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`nvim-work-indicator nvim-toolbar-button${nvimControlMode ? " is-active" : ""}${nvimSummary.controlEnabled ? " is-engaged" : ""}`}
|
||
aria-label={`Alt+3: sterowanie ${!nvimSyncMode ? "niedostępne" : nvimControlMode ? "włączone" : "wyłączone"}`}
|
||
title={!nvimSyncMode ? "Alt+3 · Sterowanie wymaga SYNC ON" : `Alt+3 · Sterowanie ${nvimControlMode ? "ON" : "OFF"}`}
|
||
aria-pressed={nvimControlMode}
|
||
disabled={!nvimSyncMode}
|
||
onClick={() => setNvimControlMode(!nvimControlMode)}
|
||
>
|
||
<kbd className="nvim-toolbar-key">A3</kbd><span className="nvim-toolbar-label">CTRL</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`nvim-height-toggle nvim-toolbar-button${nvimExpanded ? " is-active" : ""}`}
|
||
aria-label={`Alt+4: ${nvimExpanded ? "przywróć normalną wysokość" : "ustaw wysoki panel"}`}
|
||
title={nvimExpanded ? "Alt+4 · Normalna wysokość" : "Alt+4 · Wysoki panel 90%"}
|
||
aria-pressed={nvimExpanded}
|
||
onClick={() => setNvimExpanded(!nvimExpanded)}
|
||
>
|
||
<kbd className="nvim-toolbar-key">A4</kbd><span className="nvim-toolbar-label">HIGH</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`nvim-height-toggle nvim-toolbar-button${nvimFit ? " is-active" : ""}`}
|
||
aria-label={`Alt+5: automatyczne dopasowanie ${nvimFit ? "włączone" : "wyłączone"}`}
|
||
title={nvimFit ? "Alt+5 · Wyłącz auto-fit" : "Alt+5 · Pokaż cały Neovim"}
|
||
aria-pressed={nvimFit}
|
||
onClick={() => setNvimFit(!nvimFit)}
|
||
>
|
||
<kbd className="nvim-toolbar-key">A5</kbd><span className="nvim-toolbar-label">FIT</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={`nvim-height-toggle nvim-toolbar-button${allocatorVisible ? " is-active" : ""}`}
|
||
aria-label={`Alt+6: model alokatora ${allocatorVisible ? "przypięty" : "ukryty"}`}
|
||
title={allocatorVisible ? "Alt+6 · Ukryj model alokatora" : "Alt+6 · Przypnij model alokatora"}
|
||
aria-pressed={allocatorVisible}
|
||
onClick={() => setAllocatorVisible(!allocatorVisible)}
|
||
>
|
||
<kbd className="nvim-toolbar-key">A6</kbd><span className="nvim-toolbar-label">MODEL</span>
|
||
</button>
|
||
</div>
|
||
<span className="nvim-toolbar-boundary" aria-hidden="true">|</span>
|
||
{nvimVisible && (
|
||
<>
|
||
<span className={`nvim-status nvim-status--${nvimSummary.state}`}>
|
||
{nvimStatusLabel[nvimSummary.state]}
|
||
</span>
|
||
<span className="topbar-nvim-meta" title={nvimSummary.message}>
|
||
<strong>{nvimSummary.instance ?? "Neovim MCP"}</strong>
|
||
{nvimSummary.detail && <small>{nvimSummary.detail}</small>}
|
||
</span>
|
||
<span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima">
|
||
<kbd>F1</kbd> reset · <kbd>F2</kbd> sync · <kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="viewer-zoom-controls">
|
||
<output className="viewer-zoom-status">
|
||
Płótno {Math.round(scale * 100)}% · Treść 100%
|
||
</output>
|
||
<button className="viewer-zoom-reset" onClick={() => setScale(2.18)}>
|
||
Reset zoom
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
type NvimCell = { text: string; highlight: number };
|
||
type NvimHighlight = {
|
||
foreground?: number;
|
||
background?: number;
|
||
special?: number;
|
||
reverse?: boolean;
|
||
bold?: boolean;
|
||
italic?: boolean;
|
||
underline?: boolean;
|
||
undercurl?: boolean;
|
||
strikethrough?: boolean;
|
||
};
|
||
type NvimGridModel = {
|
||
width: number;
|
||
height: number;
|
||
cells: NvimCell[][];
|
||
highlights: Map<number, NvimHighlight>;
|
||
foreground: number;
|
||
background: number;
|
||
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;
|
||
focusLevel?: NavigationLevel;
|
||
activate?: boolean;
|
||
};
|
||
|
||
function allocatorStepFromModel(
|
||
model: CardModel,
|
||
navigation?: ApiNavigation | null,
|
||
): ActiveNvimStep | null {
|
||
for (const section of model.sections) {
|
||
for (const asset of section.assets) {
|
||
const interactive = asset.interactive;
|
||
if (interactive?.kind !== "uml-sequence" || !interactive.phases?.length) continue;
|
||
if (navigation?.task.id && interactive.task?.id !== navigation.task.id) continue;
|
||
if (navigation?.block.id && interactive.block?.id !== navigation.block.id) continue;
|
||
for (const phase of interactive.phases) {
|
||
if (navigation?.phase.id && phase.id !== navigation.phase.id) continue;
|
||
const step = navigation?.step.id
|
||
? phase.steps.find(item => item.id === navigation.step.id)
|
||
: phase.steps[0];
|
||
if (!step) continue;
|
||
return {
|
||
task: interactive.task ?? null,
|
||
block: interactive.block ?? null,
|
||
phase: { id: phase.id, label: phase.label },
|
||
step,
|
||
focusLevel: navigation?.focus_level ?? "step",
|
||
activate: false,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function AllocatorPanel({ visible, model }: { visible: boolean; model: CardModel }) {
|
||
const [active, setActive] = useState<ActiveNvimStep | null>(null);
|
||
|
||
useEffect(() => {
|
||
const selected = (event: Event) => {
|
||
setActive((event as CustomEvent<ActiveNvimStep>).detail ?? null);
|
||
};
|
||
window.addEventListener("stem:nvim-step", selected);
|
||
setActive(allocatorStepFromModel(model));
|
||
void fetch("/api/navigation/current", { cache: "no-store" })
|
||
.then(response => response.ok ? response.json() : Promise.reject())
|
||
.then(payload => {
|
||
const resolved = allocatorStepFromModel(model, payload.current ?? null);
|
||
if (resolved) setActive(resolved);
|
||
})
|
||
.catch(() => undefined);
|
||
return () => window.removeEventListener("stem:nvim-step", selected);
|
||
}, [model]);
|
||
|
||
if (!visible) return null;
|
||
const focusLevel = active?.focusLevel ?? "card";
|
||
const arena = active?.step.snapshot.memory.arena;
|
||
const hasUmlCursor = Boolean(active && focusLevel !== "card" && focusLevel !== "task");
|
||
const size = Math.max(1, Number(arena?.size_bytes ?? 64));
|
||
const offset = arena?.offset == null
|
||
? null
|
||
: Math.max(0, Math.min(size, Number(arena.offset)));
|
||
const byteValues = arena?.bytes.trim().split(/\s+/).filter(Boolean) ?? [];
|
||
const cellCount = Math.min(size, 64);
|
||
const facts = active
|
||
? [
|
||
...active.step.snapshot.memory.items.map(item => ({
|
||
name: item.name,
|
||
value: item.value,
|
||
})),
|
||
...active.step.snapshot.frame.fields.map(field => ({
|
||
name: field.name,
|
||
value: field.value,
|
||
})),
|
||
].filter((fact, index, all) =>
|
||
all.findIndex(candidate => candidate.name === fact.name) === index,
|
||
).slice(0, 8)
|
||
: [];
|
||
|
||
return (
|
||
<section className="allocator-panel" aria-label="Przypięty model alokatora">
|
||
<header>
|
||
<span>ALLOCATOR · PINNED</span>
|
||
<strong>
|
||
{active
|
||
? `${active.phase.label} · ${String(active.step.number).padStart(2, "0")} ${active.step.label}`
|
||
: "Oczekiwanie na diagram UML"}
|
||
</strong>
|
||
<small>
|
||
{hasUmlCursor
|
||
? active!.step.snapshot.description
|
||
: "Wybierz co najmniej BLOCK. F2 wybierze jego pierwszy krok i wykona synchronizację."}
|
||
</small>
|
||
</header>
|
||
<div className="allocator-panel-body">
|
||
<dl className="allocator-metrics">
|
||
<div><dt>FOCUS</dt><dd>{focusLevel.toUpperCase()}</dd></div>
|
||
<div><dt>BASE</dt><dd>{arena?.base ?? "—"}</dd></div>
|
||
<div><dt>ALLOCP</dt><dd>{arena?.cursor ?? "—"}</dd></div>
|
||
<div><dt>USED / FREE</dt><dd>{offset == null ? "—" : `${offset} / ${size - offset} B`}</dd></div>
|
||
</dl>
|
||
<div className={`allocator-arena-view${hasUmlCursor ? "" : " is-inactive"}`}>
|
||
<div className="allocator-arena-labels">
|
||
<span>allocbuf[0]</span>
|
||
<strong>{offset == null ? "allocp jeszcze nieustawiony" : `offset ${offset}`}</strong>
|
||
<span>allocbuf[{size - 1}]</span>
|
||
</div>
|
||
<div
|
||
className="allocator-pinned-arena"
|
||
style={{ "--allocator-size": cellCount } as React.CSSProperties}
|
||
>
|
||
{Array.from({ length: cellCount }, (_, index) => (
|
||
<i
|
||
key={index}
|
||
className={offset != null && index < offset ? "is-allocated" : ""}
|
||
title={`allocbuf[${index}] = ${byteValues[index] ?? "??"}`}
|
||
/>
|
||
))}
|
||
{offset != null && (
|
||
<b
|
||
className="allocator-cursor"
|
||
style={{ left: `${offset / size * 100}%` }}
|
||
title={`allocp = ${arena?.cursor}`}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<div className="allocator-facts" aria-label="Wartości bieżącego kroku">
|
||
{facts.length ? facts.map(fact => (
|
||
<div key={fact.name}><span>{fact.name}</span><code>{fact.value}</code></div>
|
||
)) : <p>Brak snapshotu.</p>}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
type NvimConnectionStatus = {
|
||
state: "hidden" | "connecting" | "connected" | "offline" | "unselected";
|
||
message?: string;
|
||
epoch?: string;
|
||
target?: {
|
||
instance?: string;
|
||
profile?: string;
|
||
target?: string;
|
||
container_name?: string;
|
||
container_id?: string;
|
||
};
|
||
};
|
||
type CheckpointStatus = {
|
||
state: "idle" | "replaying" | "verifying" | "ready" | "failed";
|
||
phase?: string;
|
||
message?: string;
|
||
generation?: number;
|
||
snapshot_ref?: string;
|
||
};
|
||
|
||
type SavedNvimGrid = {
|
||
schema: "stem-nvim-grid.v1";
|
||
snapshot_ref: string;
|
||
saved_at: string;
|
||
width: number;
|
||
height: number;
|
||
cells: Array<Array<[string, number]>>;
|
||
highlights: Array<[number, NvimHighlight]>;
|
||
foreground: number;
|
||
background: number;
|
||
cursor: { row: number; column: number };
|
||
};
|
||
|
||
const blankNvimCell = (): NvimCell => ({ text: " ", highlight: 0 });
|
||
|
||
function createNvimGrid(width = 1, height = 1): NvimGridModel {
|
||
return {
|
||
width,
|
||
height,
|
||
cells: Array.from({ length: height }, () =>
|
||
Array.from({ length: width }, blankNvimCell),
|
||
),
|
||
highlights: new Map(),
|
||
foreground: 0xe8e8e8,
|
||
background: 0x101315,
|
||
cursor: { row: 0, column: 0 },
|
||
};
|
||
}
|
||
|
||
function resizeNvimGrid(model: NvimGridModel, width: number, height: number) {
|
||
if (width <= 0 || height <= 0) return;
|
||
const previous = model.cells;
|
||
model.width = width;
|
||
model.height = height;
|
||
model.cells = Array.from({ length: height }, (_, row) =>
|
||
Array.from({ length: width }, (_, column) =>
|
||
previous[row]?.[column] ?? blankNvimCell(),
|
||
),
|
||
);
|
||
}
|
||
|
||
function nvimSnapshotStorageKey(snapshotRef: string) {
|
||
return `stem-card:nvim-grid:${snapshotRef}`;
|
||
}
|
||
|
||
function saveNvimGrid(snapshotRef: string, model: NvimGridModel) {
|
||
if (model.width <= 1 || model.height <= 1) return;
|
||
const saved: SavedNvimGrid = {
|
||
schema: "stem-nvim-grid.v1",
|
||
snapshot_ref: snapshotRef,
|
||
saved_at: new Date().toISOString(),
|
||
width: model.width,
|
||
height: model.height,
|
||
cells: model.cells.map(row => row.map(cell => [cell.text, cell.highlight])),
|
||
highlights: Array.from(model.highlights.entries()),
|
||
foreground: model.foreground,
|
||
background: model.background,
|
||
cursor: { ...model.cursor },
|
||
};
|
||
try {
|
||
localStorage.setItem(nvimSnapshotStorageKey(snapshotRef), JSON.stringify(saved));
|
||
} catch {
|
||
// The live view remains usable even when browser storage is full/disabled.
|
||
}
|
||
}
|
||
|
||
function parseNvimGrid(saved: SavedNvimGrid | null, snapshotRef: string): NvimGridModel | null {
|
||
try {
|
||
if (
|
||
saved?.schema !== "stem-nvim-grid.v1"
|
||
|| saved.snapshot_ref !== snapshotRef
|
||
|| !Number.isInteger(saved.width)
|
||
|| !Number.isInteger(saved.height)
|
||
|| saved.width < 2
|
||
|| saved.height < 2
|
||
|| saved.width > 400
|
||
|| saved.height > 200
|
||
|| saved.cells.length !== saved.height
|
||
) return null;
|
||
return {
|
||
width: saved.width,
|
||
height: saved.height,
|
||
cells: saved.cells.map(row => row.map(([text, highlight]) => ({
|
||
text: typeof text === "string" ? text : " ",
|
||
highlight: Number.isInteger(highlight) ? highlight : 0,
|
||
}))),
|
||
highlights: new Map(saved.highlights),
|
||
foreground: saved.foreground,
|
||
background: saved.background,
|
||
cursor: saved.cursor,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function loadNvimGrid(snapshotRef: string): NvimGridModel | null {
|
||
try {
|
||
return parseNvimGrid(
|
||
JSON.parse(
|
||
localStorage.getItem(nvimSnapshotStorageKey(snapshotRef)) ?? "null",
|
||
) as SavedNvimGrid | null,
|
||
snapshotRef,
|
||
);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function loadRecordedNvimGrid(step: UmlSequenceStep): Promise<NvimGridModel | null> {
|
||
const snapshotRef = step.snapshot_ref;
|
||
if (!snapshotRef) return null;
|
||
const local = loadNvimGrid(snapshotRef);
|
||
if (local) return local;
|
||
const recordedRef = step.view?.recorded_grid_ref;
|
||
if (!recordedRef || /^(?:[a-z]+:|\/\/)/i.test(recordedRef)) return null;
|
||
try {
|
||
const response = await fetch(recordedRef, { cache: "no-store" });
|
||
if (!response.ok) return null;
|
||
return parseNvimGrid(await response.json() as SavedNvimGrid, snapshotRef);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// The container runs Neovim in cterm mode, so the UI protocol sends palette
|
||
// indexes rather than terminal-specific RGB values. Mirror the GNOME
|
||
// Console palette used by the tmux session; the classic xterm palette makes
|
||
// blue/green syntax groups too dark (and ANSI black disappears completely).
|
||
const xterm16 = [
|
||
0x1c1c1f, 0xc01c28, 0x2ec27e, 0xf5c211,
|
||
0x1e78e4, 0x9841bb, 0x0ab9dc, 0xc0bfbc,
|
||
0x5e5c64, 0xed333b, 0x57e389, 0xf8e45c,
|
||
0x51a1ff, 0xc061cb, 0x4fd2fd, 0xf6f5f4,
|
||
];
|
||
|
||
function xterm256(index: unknown) {
|
||
const value = Number(index);
|
||
if (!Number.isInteger(value) || value < 0 || value > 255) return undefined;
|
||
if (value < 16) return xterm16[value];
|
||
if (value < 232) {
|
||
const cube = value - 16;
|
||
const levels = [0, 95, 135, 175, 215, 255];
|
||
const red = levels[Math.floor(cube / 36)];
|
||
const green = levels[Math.floor((cube % 36) / 6)];
|
||
const blue = levels[cube % 6];
|
||
return (red << 16) | (green << 8) | blue;
|
||
}
|
||
const gray = 8 + (value - 232) * 10;
|
||
return (gray << 16) | (gray << 8) | gray;
|
||
}
|
||
|
||
function ctermHighlight(attributes: unknown): NvimHighlight | null {
|
||
if (!attributes || typeof attributes !== "object") return null;
|
||
const source = attributes as NvimHighlight;
|
||
if (!Object.keys(source).length) return null;
|
||
return {
|
||
...source,
|
||
foreground: xterm256(source.foreground),
|
||
background: xterm256(source.background),
|
||
special: xterm256(source.special),
|
||
};
|
||
}
|
||
|
||
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;
|
||
const name = rawEvent[0];
|
||
const updates = rawEvent.slice(1);
|
||
for (const update of updates) {
|
||
if (!Array.isArray(update)) continue;
|
||
if (name === "grid_resize") {
|
||
const [grid, width, height] = update.map(Number);
|
||
if (grid === 1) resizeNvimGrid(model, width, height);
|
||
} else if (name === "default_colors_set") {
|
||
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]);
|
||
// 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);
|
||
}
|
||
} else if (name === "grid_clear" && Number(update[0]) === 1) {
|
||
model.cells = Array.from({ length: model.height }, () =>
|
||
Array.from({ length: model.width }, blankNvimCell),
|
||
);
|
||
} else if (name === "grid_cursor_goto" && Number(update[0]) === 1) {
|
||
model.cursor = { row: Number(update[1]), column: Number(update[2]) };
|
||
} else if (name === "grid_line" && Number(update[0]) === 1) {
|
||
const row = Number(update[1]);
|
||
let column = Number(update[2]);
|
||
const cells = update[3];
|
||
if (!model.cells[row] || !Array.isArray(cells)) continue;
|
||
let highlight = 0;
|
||
for (const encodedCell of cells) {
|
||
if (!Array.isArray(encodedCell)) continue;
|
||
const text = typeof encodedCell[0] === "string" ? encodedCell[0] : " ";
|
||
if (Number.isInteger(encodedCell[1])) highlight = Number(encodedCell[1]);
|
||
const repeat = Number.isInteger(encodedCell[2]) ? Number(encodedCell[2]) : 1;
|
||
for (let index = 0; index < repeat && column < model.width; index += 1) {
|
||
if (column >= 0) model.cells[row][column] = { text, highlight };
|
||
column += 1;
|
||
}
|
||
}
|
||
} else if (name === "grid_scroll" && Number(update[0]) === 1) {
|
||
const top = Number(update[1]);
|
||
const bottom = Number(update[2]);
|
||
const left = Number(update[3]);
|
||
const right = Number(update[4]);
|
||
const rows = Number(update[5]);
|
||
const columns = Number(update[6]);
|
||
const copy = model.cells.map(row => row.map(cell => ({ ...cell })));
|
||
for (let row = top; row < bottom; row += 1) {
|
||
for (let column = left; column < right; column += 1) {
|
||
const sourceRow = row + rows;
|
||
const sourceColumn = column + columns;
|
||
model.cells[row][column] =
|
||
sourceRow >= top && sourceRow < bottom &&
|
||
sourceColumn >= left && sourceColumn < right
|
||
? copy[sourceRow][sourceColumn]
|
||
: blankNvimCell();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function nvimColor(value: number | undefined, fallback: string) {
|
||
if (!Number.isFinite(value) || Number(value) < 0) return fallback;
|
||
return `#${Number(value).toString(16).padStart(6, "0").slice(-6)}`;
|
||
}
|
||
|
||
function matchingTextColumns(line: NvimCell[], text: string) {
|
||
const content = line.map(cell => cell.text || " ").join("");
|
||
const matches: number[] = [];
|
||
let from = 0;
|
||
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 matches;
|
||
}
|
||
|
||
function paintNvimRows(
|
||
canvas: HTMLCanvasElement,
|
||
model: NvimGridModel,
|
||
startRow: number,
|
||
rowCount: number,
|
||
) {
|
||
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 = 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);
|
||
canvas.style.width = `${width}px`;
|
||
canvas.style.height = `${height}px`;
|
||
}
|
||
canvas.dataset.columns = String(model.width);
|
||
canvas.dataset.rows = String(visibleRows);
|
||
const context = canvas.getContext("2d");
|
||
if (!context) return null;
|
||
context.setTransform(ratio, 0, 0, ratio, 0, 0);
|
||
context.imageSmoothingEnabled = false;
|
||
const defaultForeground = nvimColor(model.foreground, "#e8e8e8");
|
||
const defaultBackground = nvimColor(model.background, "#101315");
|
||
context.fillStyle = defaultBackground;
|
||
context.fillRect(0, 0, width, height);
|
||
context.textBaseline = "alphabetic";
|
||
|
||
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) ?? {};
|
||
let foreground = nvimColor(highlight.foreground, defaultForeground);
|
||
let background = nvimColor(highlight.background, defaultBackground);
|
||
if (highlight.reverse) [foreground, background] = [background, foreground];
|
||
if (background !== defaultBackground) {
|
||
context.fillStyle = background;
|
||
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, visibleRow * cellHeight + cellHeight * 0.76);
|
||
}
|
||
if (highlight.underline || highlight.undercurl || highlight.strikethrough) {
|
||
context.strokeStyle = nvimColor(highlight.special, foreground);
|
||
context.lineWidth = 1;
|
||
const y = visibleRow * cellHeight + (highlight.strikethrough ? cellHeight * 0.5 : cellHeight - 2);
|
||
context.beginPath();
|
||
context.moveTo(column * cellWidth, y);
|
||
context.lineTo((column + 1) * cellWidth, y);
|
||
context.stroke();
|
||
}
|
||
}
|
||
}
|
||
|
||
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",
|
||
info: "#35b9ef",
|
||
success: "#2ac56f",
|
||
warning: "#ffbd2e",
|
||
};
|
||
for (const annotation of annotations) {
|
||
const anchor = annotation.anchor;
|
||
let row = Number(anchor.row ?? -1);
|
||
let column = Number(anchor.column ?? -1);
|
||
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) {
|
||
const matches: Array<{ row: number; column: number }> = [];
|
||
for (let candidate = 0; candidate < model.height; candidate += 1) {
|
||
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));
|
||
const x = Math.max(0, column - padding) * cellWidth;
|
||
const y = Math.max(0, row - padding) * cellHeight;
|
||
const boxWidth = Math.min(model.width - column + padding, columns + padding * 2) * cellWidth;
|
||
const boxHeight = Math.min(model.height - row + padding, rows + padding * 2) * cellHeight;
|
||
const color = toneColors[annotation.tone ?? "danger"];
|
||
context.strokeStyle = color;
|
||
context.lineWidth = 2;
|
||
if (annotation.shape === "underline") {
|
||
context.beginPath();
|
||
context.moveTo(x, y + boxHeight - 2);
|
||
context.lineTo(x + boxWidth, y + boxHeight - 2);
|
||
context.stroke();
|
||
} else {
|
||
context.strokeRect(x + 1, y + 1, Math.max(1, boxWidth - 2), Math.max(1, boxHeight - 2));
|
||
}
|
||
if (annotation.label) {
|
||
context.font = "700 10px system-ui, sans-serif";
|
||
const labelWidth = context.measureText(annotation.label).width + 8;
|
||
context.fillStyle = color;
|
||
context.fillRect(x + 1, Math.max(0, y - 15), labelWidth, 14);
|
||
context.fillStyle = "#fff";
|
||
context.fillText(annotation.label, x + 5, Math.max(10, y - 4));
|
||
}
|
||
}
|
||
}
|
||
|
||
function keyForNvim(event: React.KeyboardEvent<HTMLElement>) {
|
||
if (event.nativeEvent.isComposing || event.metaKey) return null;
|
||
const special: Record<string, string> = {
|
||
Enter: "CR",
|
||
Escape: "Esc",
|
||
Backspace: "BS",
|
||
Delete: "Del",
|
||
Tab: "Tab",
|
||
ArrowLeft: "Left",
|
||
ArrowRight: "Right",
|
||
ArrowUp: "Up",
|
||
ArrowDown: "Down",
|
||
Home: "Home",
|
||
End: "End",
|
||
PageUp: "PageUp",
|
||
PageDown: "PageDown",
|
||
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("-");
|
||
if (base) return `<${modifiers ? `${modifiers}-` : ""}${base}>`;
|
||
if (event.key.length !== 1) return null;
|
||
if (event.ctrlKey || event.altKey) {
|
||
return `<${modifiers}-${event.key === "<" ? "lt" : event.key}>`;
|
||
}
|
||
return event.key === "<" ? "<lt>" : event.key;
|
||
}
|
||
|
||
function NeovimPanel({
|
||
visible,
|
||
syncMode,
|
||
controlMode,
|
||
expanded,
|
||
fit,
|
||
scale,
|
||
onSummaryChange,
|
||
}: {
|
||
visible: boolean;
|
||
syncMode: boolean;
|
||
controlMode: boolean;
|
||
expanded: boolean;
|
||
fit: 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 gridReportTimerRef = 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);
|
||
const checkpointRequestRef = useRef<AbortController | null>(null);
|
||
const resizeStartRef = useRef<{ y: number; heightVh: number } | null>(null);
|
||
const [activeStep, setActiveStep] = useState<ActiveNvimStep | null>(null);
|
||
const [hasGrid, setHasGrid] = useState(false);
|
||
const [gridRevision, setGridRevision] = useState(0);
|
||
const [pointerInside, setPointerInside] = useState(false);
|
||
const [panelHeightVh, setPanelHeightVh] = useState(() => {
|
||
const saved = Number(localStorage.getItem("stem-card-nvim-height-vh"));
|
||
return Number.isFinite(saved) && saved >= 25 && saved <= 90 ? saved : 75;
|
||
});
|
||
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 = controlMode && pointerInside && status.state === "connected" && !checkpointBusy;
|
||
|
||
const redraw = () => {
|
||
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(() => {
|
||
const selected = (event: Event) => {
|
||
const detail = (event as CustomEvent<ActiveNvimStep>).detail;
|
||
activeStepRef.current = detail;
|
||
setActiveStep(detail);
|
||
presentedSnapshotRef.current = null;
|
||
const snapshotRef = detail.step.snapshot_ref;
|
||
if (status.state !== "connected" && snapshotRef) {
|
||
void loadRecordedNvimGrid(detail.step).then(saved => {
|
||
if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
|
||
gridRef.current = saved;
|
||
setHasGrid(true);
|
||
presentedSnapshotRef.current = snapshotRef;
|
||
setCheckpoint({
|
||
state: "idle",
|
||
snapshot_ref: snapshotRef,
|
||
message: "Pokazano ostatni zapisany, zweryfikowany ekran.",
|
||
});
|
||
redraw();
|
||
});
|
||
}
|
||
redraw();
|
||
|
||
if (!detail.activate || !snapshotRef) return;
|
||
if (!visible || status.state !== "connected") {
|
||
setCheckpoint({
|
||
state: "failed",
|
||
snapshot_ref: snapshotRef,
|
||
message: "Cel jest offline; pozostaje zapisany wynik tego kroku.",
|
||
});
|
||
return;
|
||
}
|
||
|
||
const generation = Math.max(checkpointGenerationRef.current + 1, Date.now());
|
||
checkpointGenerationRef.current = generation;
|
||
checkpointRequestRef.current?.abort();
|
||
const controller = new AbortController();
|
||
checkpointRequestRef.current = controller;
|
||
setCheckpoint({
|
||
state: "replaying",
|
||
phase: "dispatch",
|
||
generation,
|
||
snapshot_ref: snapshotRef,
|
||
message: "Resetuję cel i odtwarzam wykonanie do wybranego kroku.",
|
||
});
|
||
|
||
let pollTimer = 0;
|
||
const poll = async () => {
|
||
try {
|
||
const response = await fetch("/api/checkpoint/status", {
|
||
cache: "no-store",
|
||
signal: controller.signal,
|
||
});
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
const current = payload.active?.generation === generation
|
||
? payload.active
|
||
: null;
|
||
if (!current || checkpointGenerationRef.current !== generation) return;
|
||
setCheckpoint({
|
||
state: current.phase === "verify" ? "verifying" : "replaying",
|
||
phase: current.phase,
|
||
generation,
|
||
snapshot_ref: snapshotRef,
|
||
message: current.message,
|
||
});
|
||
} catch {
|
||
// POST below owns the final error; polling is only progress feedback.
|
||
}
|
||
};
|
||
pollTimer = window.setInterval(poll, 120);
|
||
void poll();
|
||
|
||
void fetch("/api/checkpoint/activate", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ snapshot_ref: snapshotRef, generation }),
|
||
signal: controller.signal,
|
||
})
|
||
.then(async response => {
|
||
const result = await response.json();
|
||
if (checkpointGenerationRef.current !== generation) return;
|
||
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",
|
||
phase: result.phase,
|
||
generation,
|
||
snapshot_ref: snapshotRef,
|
||
message: result.message ?? "Stan maszyny został odtworzony i zweryfikowany.",
|
||
});
|
||
saveNvimGrid(snapshotRef, gridRef.current);
|
||
redraw();
|
||
await new Promise<void>(resolve => window.requestAnimationFrame(() => resolve()));
|
||
const canvas = canvasRef.current;
|
||
if (canvas && activeStepRef.current?.step.snapshot_ref === snapshotRef) {
|
||
try {
|
||
const dataUrl = canvas.toDataURL("image/png");
|
||
void fetch("/api/evidence", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
actor: document.documentElement.dataset.cardClientId ?? "browser",
|
||
snapshot_ref: snapshotRef,
|
||
caption: `${String(detail.step.number).padStart(2, "0")} ${detail.step.label}`,
|
||
data_url: dataUrl,
|
||
}),
|
||
}).catch(() => undefined);
|
||
} catch {
|
||
// Evidence is auxiliary; a failed screenshot must not invalidate replay.
|
||
}
|
||
}
|
||
})
|
||
.catch(error => {
|
||
if (controller.signal.aborted || checkpointGenerationRef.current !== generation) return;
|
||
void loadRecordedNvimGrid(detail.step).then(saved => {
|
||
if (!saved || activeStepRef.current?.step.snapshot_ref !== snapshotRef) return;
|
||
gridRef.current = saved;
|
||
setHasGrid(true);
|
||
presentedSnapshotRef.current = snapshotRef;
|
||
redraw();
|
||
});
|
||
setCheckpoint({
|
||
state: "failed",
|
||
generation,
|
||
snapshot_ref: snapshotRef,
|
||
message: error instanceof Error ? error.message : String(error),
|
||
});
|
||
})
|
||
.finally(() => {
|
||
window.clearInterval(pollTimer);
|
||
if (checkpointRequestRef.current === controller) checkpointRequestRef.current = null;
|
||
});
|
||
};
|
||
window.addEventListener("stem:nvim-step", selected);
|
||
return () => {
|
||
window.removeEventListener("stem:nvim-step", selected);
|
||
checkpointRequestRef.current?.abort();
|
||
};
|
||
}, [status.state, visible]);
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem("stem-card-nvim-height-vh", panelHeightVh.toFixed(2));
|
||
}, [panelHeightVh]);
|
||
|
||
useEffect(() => {
|
||
const frame = window.requestAnimationFrame(redraw);
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [expanded, fit, scale]);
|
||
|
||
useEffect(() => {
|
||
const refresh = () => {
|
||
const socket = websocketRef.current;
|
||
if (socket?.readyState === WebSocket.OPEN) {
|
||
socket.send(JSON.stringify({ type: "refresh" }));
|
||
}
|
||
redraw();
|
||
};
|
||
window.addEventListener("stem:nvim-refresh", refresh);
|
||
return () => window.removeEventListener("stem:nvim-refresh", refresh);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let summaryState: NvimPanelSummary["state"] = status.state;
|
||
if (status.state === "connected") {
|
||
summaryState = checkpoint.state === "idle" ? "connected" : checkpoint.state;
|
||
}
|
||
onSummaryChange({
|
||
state: summaryState,
|
||
message: checkpoint.message ?? status.message,
|
||
instance: status.target?.instance ?? status.target?.container_name,
|
||
detail: [
|
||
status.target?.profile ?? activeStep?.step.view?.connection_ref,
|
||
activeStep
|
||
? `${String(activeStep.step.number).padStart(2, "0")} ${activeStep.step.label}`
|
||
: undefined,
|
||
checkpoint.phase,
|
||
].filter(Boolean).join(" · "),
|
||
syncMode,
|
||
syncReady,
|
||
controlMode,
|
||
controlEnabled,
|
||
screenCursor: { ...gridRef.current.cursor },
|
||
grid: {
|
||
columns: gridRef.current.width,
|
||
rows: gridRef.current.height,
|
||
},
|
||
});
|
||
}, [activeStep, checkpoint, controlEnabled, controlMode, gridRevision, onSummaryChange, status, syncMode, syncReady]);
|
||
|
||
useEffect(() => () => window.clearTimeout(gridReportTimerRef.current), []);
|
||
|
||
useEffect(() => {
|
||
if (!controlEnabled) return;
|
||
const protectBrowserShortcuts = (event: KeyboardEvent) => {
|
||
const socket = websocketRef.current;
|
||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||
if (event.altKey && !event.ctrlKey && !event.metaKey && event.key.toLowerCase() === "w") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
if (!event.repeat) socket.send(JSON.stringify({ type: "input", keys: "<M-w>" }));
|
||
return;
|
||
}
|
||
const directions: Record<string, string> = {
|
||
ArrowLeft: "h",
|
||
ArrowDown: "j",
|
||
ArrowUp: "k",
|
||
ArrowRight: "l",
|
||
};
|
||
if (event.altKey && !event.ctrlKey && !event.metaKey && directions[event.key]) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
if (!event.repeat) {
|
||
socket.send(JSON.stringify({ type: "input", keys: `<M-w>${directions[event.key]}` }));
|
||
}
|
||
}
|
||
};
|
||
window.addEventListener("keydown", protectBrowserShortcuts, true);
|
||
return () => window.removeEventListener("keydown", protectBrowserShortcuts, true);
|
||
}, [controlEnabled]);
|
||
|
||
useEffect(() => {
|
||
if (!visible || !syncMode) {
|
||
websocketRef.current?.close();
|
||
websocketRef.current = null;
|
||
setPointerInside(false);
|
||
setStatus(visible
|
||
? { state: "offline", message: "SYNC OFF: pokazuję zapisany ekran bez połączenia z kontenerem." }
|
||
: { state: "hidden" });
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
let reconnectTimer = 0;
|
||
const connect = () => {
|
||
if (cancelled) return;
|
||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||
const socket = new WebSocket(`${protocol}//${window.location.host}/api/nvim-ui`);
|
||
websocketRef.current = socket;
|
||
setStatus({ state: "connecting" });
|
||
socket.addEventListener("message", event => {
|
||
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);
|
||
if (message.events.some(
|
||
(event: unknown) => Array.isArray(event) && event[0] === "flush",
|
||
)) {
|
||
gridFlushRef.current += 1;
|
||
scheduleRedraw();
|
||
if (!gridReportTimerRef.current) {
|
||
gridReportTimerRef.current = window.setTimeout(() => {
|
||
gridReportTimerRef.current = 0;
|
||
setGridRevision(current => current + 1);
|
||
}, 120);
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore malformed local bridge frames and keep the last good grid.
|
||
}
|
||
});
|
||
socket.addEventListener("close", () => {
|
||
if (websocketRef.current === socket) websocketRef.current = null;
|
||
if (!cancelled) reconnectTimer = window.setTimeout(connect, 1200);
|
||
});
|
||
socket.addEventListener("error", () => {
|
||
setStatus(current => ({
|
||
...current,
|
||
state: "offline",
|
||
message: "Most Neovima jest chwilowo niedostępny.",
|
||
}));
|
||
});
|
||
};
|
||
connect();
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearTimeout(reconnectTimer);
|
||
window.cancelAnimationFrame(redrawFrameRef.current);
|
||
redrawFrameRef.current = 0;
|
||
websocketRef.current?.close();
|
||
websocketRef.current = null;
|
||
};
|
||
}, [syncMode, visible]);
|
||
|
||
if (!visible) return null;
|
||
const send = (message: object) => {
|
||
const socket = websocketRef.current;
|
||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message));
|
||
};
|
||
const locateMouse = (event: React.MouseEvent<HTMLCanvasElement>) => {
|
||
const bounds = event.currentTarget.getBoundingClientRect();
|
||
return {
|
||
row: Math.max(0, Math.min(
|
||
gridRef.current.height - 1,
|
||
Math.floor(((event.clientY - bounds.top) / bounds.height) * gridRef.current.height),
|
||
)),
|
||
column: Math.max(0, Math.min(
|
||
gridRef.current.width - 1,
|
||
Math.floor(((event.clientX - bounds.left) / bounds.width) * gridRef.current.width),
|
||
)),
|
||
};
|
||
};
|
||
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"
|
||
onWheel={event => {
|
||
event.stopPropagation();
|
||
const target = event.target;
|
||
if (!(target instanceof Element) || !target.closest(".nvim-screen-scroll")) {
|
||
event.preventDefault();
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
ref={screenRef}
|
||
className="nvim-screen-scroll"
|
||
style={fit ? undefined : { height: `${expanded ? 90 : panelHeightVh}vh` }}
|
||
data-sync={syncMode ? "on" : "off"}
|
||
data-control={controlEnabled ? "true" : "false"}
|
||
tabIndex={0}
|
||
onPointerEnter={event => {
|
||
setPointerInside(true);
|
||
event.currentTarget.focus({ preventScroll: true });
|
||
}}
|
||
onPointerLeave={event => {
|
||
setPointerInside(false);
|
||
event.currentTarget.blur();
|
||
}}
|
||
onMouseDown={event => event.currentTarget.focus({ preventScroll: true })}
|
||
onKeyDown={event => {
|
||
if (!controlEnabled) return;
|
||
const keys = keyForNvim(event);
|
||
if (!keys) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
send({ type: "input", keys });
|
||
}}
|
||
>
|
||
<div
|
||
className="nvim-screen-stage"
|
||
style={fit ? undefined : { height: `${(expanded ? 90 : 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>
|
||
<span>{status.message ?? "Oczekiwanie na ekran Neovima lub zapisany stan kroku."}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div
|
||
className="nvim-resize-handle"
|
||
role="separator"
|
||
aria-label="Zmień wysokość panelu Neovima"
|
||
aria-orientation="horizontal"
|
||
aria-valuemin={25}
|
||
aria-valuemax={90}
|
||
aria-valuenow={Math.round(panelHeightVh)}
|
||
tabIndex={0}
|
||
title="Przeciągnij, aby zmienić wysokość. Dwuklik: 75%."
|
||
onPointerDown={event => {
|
||
event.preventDefault();
|
||
resizeStartRef.current = { y: event.clientY, heightVh: panelHeightVh };
|
||
event.currentTarget.setPointerCapture(event.pointerId);
|
||
}}
|
||
onPointerMove={event => {
|
||
const start = resizeStartRef.current;
|
||
if (!start) return;
|
||
const deltaVh = ((event.clientY - start.y) / window.innerHeight) * 100;
|
||
setPanelHeightVh(Math.max(25, Math.min(90, start.heightVh + deltaVh)));
|
||
}}
|
||
onPointerUp={event => {
|
||
resizeStartRef.current = null;
|
||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||
}
|
||
}}
|
||
onPointerCancel={() => { resizeStartRef.current = null; }}
|
||
onDoubleClick={() => setPanelHeightVh(75)}
|
||
onKeyDown={event => {
|
||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||
event.preventDefault();
|
||
const direction = event.key === "ArrowUp" ? -2 : 2;
|
||
setPanelHeightVh(value => Math.max(25, Math.min(90, value + direction)));
|
||
} else if (event.key === "Home") {
|
||
event.preventDefault();
|
||
setPanelHeightVh(75);
|
||
}
|
||
}}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ScopeTableCell({ column, row }: { column: string; row: any }) {
|
||
if (column === "chapter" || column === "task" || column === "version") {
|
||
return <td><code>{row[column]}</code></td>;
|
||
}
|
||
if (column === "idea") {
|
||
return <td dangerouslySetInnerHTML={{ __html: row.idea_html }} />;
|
||
}
|
||
if (column === "status") {
|
||
return (
|
||
<td>
|
||
<span className="scope-status" data-status={row.status} title={row.status}>
|
||
{row.status_label}
|
||
</span>
|
||
</td>
|
||
);
|
||
}
|
||
return <td>{row[column]}</td>;
|
||
}
|
||
|
||
function FrontPage({ front }: { front: any }) {
|
||
const scopeColumns: string[] = front.scope_columns ?? ["chapter", "task", "idea", "priority"];
|
||
return (
|
||
<Sheet
|
||
header={front.header_html}
|
||
left={front.left_margin_html}
|
||
right={front.right_margin_html}
|
||
className="front-sheet"
|
||
>
|
||
<section className="front-scope front-goal">
|
||
<h2>{front.goal_title}</h2>
|
||
<Html html={front.goal_html} />
|
||
</section>
|
||
<section className="front-scope front-card-scope">
|
||
<h2>{front.scope_title}</h2>
|
||
{front.scope_html && <Html html={front.scope_html} />}
|
||
<div className="scope-table-wrap">
|
||
<table className="scope-table scope-table--task">
|
||
<thead>
|
||
<tr>
|
||
{front.scope_headers.map((header: string) => (
|
||
<th key={header}>{header}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{front.scope_rows.map((row: any) => (
|
||
<tr
|
||
key={`${row.chapter}-${row.task}`}
|
||
className={row.key ? "scope-row--key" : ""}
|
||
>
|
||
{scopeColumns.map(column => (
|
||
<ScopeTableCell key={column} column={column} row={row} />
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function TaskStep({ taskRef, step }: { taskRef: string; step: FlowStep }) {
|
||
return (
|
||
<li id={`${taskRef}-${step.id}`}>
|
||
<strong>{step.title}</strong>
|
||
<Html html={step.content_html} />
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function TaskBlock({ taskRef, block }: { taskRef: string; block: FlowBlock }) {
|
||
const label = block.kind === "exercise" ? "Ćwiczenie" : "Block";
|
||
return (
|
||
<section
|
||
className={`task-block task-block--${block.kind}`}
|
||
id={`${taskRef}-${block.id}`}
|
||
data-stem-nav="block"
|
||
>
|
||
<header>
|
||
<span>{label}</span>
|
||
<h4>{block.title}</h4>
|
||
</header>
|
||
<Html html={block.content_html} />
|
||
{block.steps.length > 0 && (
|
||
<ol className="block-steps">
|
||
{block.steps.map((step) => (
|
||
<TaskStep key={step.id} taskRef={taskRef} step={step} />
|
||
))}
|
||
</ol>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function TaskFlow({ task }: { task: Task }) {
|
||
if (!task.flow.length)
|
||
return (
|
||
<article className="task-legacy" id={task.ref} data-stem-nav="task">
|
||
<strong>{task.ref.toUpperCase()}</strong>
|
||
<Html html={task.prompt_html ?? ""} />
|
||
<footer>
|
||
<strong>Task acceptance:</strong> {task.criterion}
|
||
</footer>
|
||
</article>
|
||
);
|
||
return (
|
||
<article className="task-flow" id={task.ref} data-stem-nav="task">
|
||
<header className="task-flow-header">
|
||
<span>{task.ref.toUpperCase()}</span>
|
||
<h3>{task.title}</h3>
|
||
{task.uuid && <code>{task.uuid}</code>}
|
||
</header>
|
||
{task.flow.map((block) => (
|
||
<TaskBlock key={block.id} taskRef={task.ref} block={block} />
|
||
))}
|
||
<footer>
|
||
<strong>Task acceptance:</strong> {task.criterion}
|
||
</footer>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
const areaLabels: Record<InteractiveArea, string> = {
|
||
compile: "KOMPILACJA",
|
||
bss: ".BSS",
|
||
stack: "STOS",
|
||
registers: "REJESTRY",
|
||
text: ".TEXT",
|
||
};
|
||
const allocationColors = ["#9bcfe2", "#c4dfc0", "#f4c889", "#d2b6dc"];
|
||
|
||
function InteractiveMemoryFlow({
|
||
config,
|
||
title,
|
||
}: {
|
||
config: InteractiveAsset;
|
||
title: string;
|
||
}) {
|
||
const arenaSize = config.arena_size ?? 64;
|
||
const allocations = config.allocations ?? [5, 7];
|
||
const symbols = config.symbols ?? [];
|
||
const storageKey = `${config.storage_key ?? "allocator-memory-flow"}-stage`;
|
||
const [stage, setStage] = useState(() =>
|
||
Math.min(allocations.length, Number(localStorage.getItem(storageKey) ?? 0)),
|
||
);
|
||
const [focus, setFocus] = useState(symbols[0]?.id ?? "");
|
||
useEffect(
|
||
() => localStorage.setItem(storageKey, String(stage)),
|
||
[stage, storageKey],
|
||
);
|
||
const used = allocations.slice(0, stage).reduce((sum, size) => sum + size, 0);
|
||
const focused = symbols.find((symbol) => symbol.id === focus) ?? symbols[0];
|
||
const cells = Array.from({ length: arenaSize }, (_, index) => index);
|
||
const allocationIndex = (cell: number) => {
|
||
let end = 0;
|
||
for (let index = 0; index < stage; index += 1) {
|
||
end += allocations[index];
|
||
if (cell < end) return index;
|
||
}
|
||
return -1;
|
||
};
|
||
const stageText =
|
||
stage === 0
|
||
? "allocator_reset(): allocp = allocbuf"
|
||
: `alloc_local(${allocations[stage - 1]}): allocp = allocbuf + ${used}`;
|
||
const areas = (Object.keys(areaLabels) as InteractiveArea[]).filter((area) =>
|
||
symbols.some((symbol) => symbol.area === area),
|
||
);
|
||
return (
|
||
<div className="memory-react" aria-label={`Interaktywny diagram: ${title}`}>
|
||
<header>
|
||
<div>
|
||
<small>INTERAKTYWNY MODEL PAMIĘCI</small>
|
||
<h3>{title}</h3>
|
||
</div>
|
||
<div className="memory-actions">
|
||
<button aria-pressed={stage === 0} onClick={() => setStage(0)}>
|
||
definicje / reset
|
||
</button>
|
||
{allocations.map((size, index) => (
|
||
<button
|
||
key={`${size}-${index}`}
|
||
aria-pressed={stage === index + 1}
|
||
onClick={() => setStage(index + 1)}
|
||
>
|
||
alloc({size})
|
||
</button>
|
||
))}
|
||
</div>
|
||
</header>
|
||
<div className="memory-lanes">
|
||
{areas.map((area) => (
|
||
<section className={`memory-lane ${area}`} key={area}>
|
||
<b>{areaLabels[area]}</b>
|
||
{symbols
|
||
.filter((symbol) => symbol.area === area)
|
||
.map((symbol) => (
|
||
<button
|
||
key={symbol.id}
|
||
className={focus === symbol.id ? "active" : ""}
|
||
onClick={() => setFocus(symbol.id)}
|
||
>
|
||
<code>{symbol.declaration}</code>
|
||
</button>
|
||
))}
|
||
</section>
|
||
))}
|
||
</div>
|
||
<div className="arena-wrap">
|
||
<div className="arena-label">
|
||
<code>allocbuf[{arenaSize}]</code>
|
||
<span>
|
||
użyte {used} B / wolne {arenaSize - used} B
|
||
</span>
|
||
</div>
|
||
<div
|
||
className="arena"
|
||
style={
|
||
{
|
||
"--allocp": used,
|
||
"--arena-size": arenaSize,
|
||
} as React.CSSProperties
|
||
}
|
||
>
|
||
{cells.map((cell) => {
|
||
const segment = allocationIndex(cell);
|
||
return (
|
||
<i
|
||
key={cell}
|
||
style={
|
||
segment >= 0
|
||
? {
|
||
background:
|
||
allocationColors[segment % allocationColors.length],
|
||
}
|
||
: undefined
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
<span className="allocp">allocp ↑ {used}</span>
|
||
</div>
|
||
</div>
|
||
<p className="memory-stage">
|
||
<strong>
|
||
{stage + 1}/{allocations.length + 1}
|
||
</strong>{" "}
|
||
<code>{stageText}</code>
|
||
</p>
|
||
{focused && (
|
||
<aside className="memory-inspector">
|
||
<strong>{focused.id}</strong>
|
||
<code>{focused.declaration}</code>
|
||
<span>{focused.description}</span>
|
||
</aside>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LegacyInteractiveUmlStages({
|
||
config,
|
||
asset,
|
||
progress,
|
||
onSetStatus,
|
||
}: {
|
||
config: InteractiveAsset;
|
||
asset: Asset;
|
||
progress: Progress | null;
|
||
onSetStatus: SetProgressStatus;
|
||
}) {
|
||
const stages = config.stages ?? [];
|
||
const storageKey = `${config.storage_key ?? asset.label}-uml-stage`;
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
const [svgMarkup, setSvgMarkup] = useState("");
|
||
const [svgFailed, setSvgFailed] = useState(false);
|
||
const [approvalBusy, setApprovalBusy] = useState(false);
|
||
const [stageIndex, setStageIndex] = useState(() =>
|
||
Math.max(
|
||
0,
|
||
Math.min(
|
||
stages.length - 1,
|
||
Number(localStorage.getItem(storageKey) ?? 0),
|
||
),
|
||
),
|
||
);
|
||
useEffect(
|
||
() => localStorage.setItem(storageKey, String(stageIndex)),
|
||
[stageIndex, storageKey],
|
||
);
|
||
useEffect(() => {
|
||
let current = true;
|
||
setSvgFailed(false);
|
||
fetch(asset.href, { cache: "no-store" })
|
||
.then((response) => {
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
return response.text();
|
||
})
|
||
.then((markup) => {
|
||
const document = new DOMParser().parseFromString(
|
||
markup,
|
||
"image/svg+xml",
|
||
);
|
||
const svg = document.documentElement;
|
||
if (svg.nodeName.toLowerCase() !== "svg" || document.querySelector("parsererror")) {
|
||
throw new Error("Niepoprawny SVG");
|
||
}
|
||
document.querySelectorAll("script, foreignObject").forEach((node) => node.remove());
|
||
svg.removeAttribute("width");
|
||
svg.removeAttribute("height");
|
||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||
if (current) setSvgMarkup(svg.outerHTML);
|
||
})
|
||
.catch(() => {
|
||
if (current) setSvgFailed(true);
|
||
});
|
||
return () => {
|
||
current = false;
|
||
};
|
||
}, [asset.href]);
|
||
useEffect(() => {
|
||
const svg = canvasRef.current?.querySelector("svg");
|
||
if (!svg) return;
|
||
|
||
svg.setAttribute("role", "img");
|
||
svg.setAttribute("aria-label", asset.alt);
|
||
svg
|
||
.querySelectorAll<SVGRectElement>("[data-uml-stage]")
|
||
.forEach((rect) => {
|
||
rect.classList.remove(
|
||
"uml-stage-region",
|
||
"uml-stage-region-bg",
|
||
"uml-stage-region-outline",
|
||
"uml-stage-region-active",
|
||
"uml-stage-region-completed",
|
||
);
|
||
rect.removeAttribute("data-uml-stage");
|
||
rect.removeAttribute("data-uml-stage-label");
|
||
rect.removeAttribute("role");
|
||
rect.removeAttribute("tabindex");
|
||
rect.removeAttribute("aria-label");
|
||
});
|
||
|
||
const texts = Array.from(svg.querySelectorAll<SVGTextElement>("text"));
|
||
const rects = Array.from(svg.querySelectorAll<SVGRectElement>("rect"));
|
||
stages.forEach((item, index) => {
|
||
if (!item.svg_label) return;
|
||
const label = texts.find((text) =>
|
||
(text.textContent ?? "").trim().startsWith(item.svg_label!),
|
||
);
|
||
const labelY = Number(label?.getAttribute("y"));
|
||
if (!label || !Number.isFinite(labelY)) return;
|
||
|
||
const candidates = rects
|
||
.map((rect) => {
|
||
const y = Number(rect.getAttribute("y"));
|
||
const height = Number(rect.getAttribute("height"));
|
||
const width = Number(rect.getAttribute("width"));
|
||
const style = rect.getAttribute("style") ?? "";
|
||
const strokeWidth = Number(
|
||
style.match(/stroke-width:\s*([\d.]+)/)?.[1] ??
|
||
rect.getAttribute("stroke-width") ??
|
||
0,
|
||
);
|
||
return { rect, y, height, width, strokeWidth, area: width * height };
|
||
})
|
||
.filter(
|
||
({ y, height, width, strokeWidth }) =>
|
||
Number.isFinite(y) &&
|
||
Number.isFinite(height) &&
|
||
Number.isFinite(width) &&
|
||
strokeWidth >= 2 &&
|
||
y <= labelY &&
|
||
y + height >= labelY,
|
||
)
|
||
.sort((left, right) => left.area - right.area);
|
||
const region = candidates[0];
|
||
if (!region) return;
|
||
|
||
const sameRegion = candidates.filter(
|
||
({ y, height, width }) =>
|
||
Math.abs(y - region.y) < 0.1 &&
|
||
Math.abs(height - region.height) < 0.1 &&
|
||
Math.abs(width - region.width) < 0.1,
|
||
);
|
||
const isCompleted = Boolean(
|
||
item.progress_id &&
|
||
progress?.items?.[item.progress_id]?.status === "approved",
|
||
);
|
||
sameRegion.forEach(({ rect }, duplicateIndex) => {
|
||
const isBackground = rect.getAttribute("fill") !== "none";
|
||
rect.dataset.umlStage = String(index);
|
||
rect.dataset.umlStageLabel = item.label;
|
||
rect.classList.add(
|
||
"uml-stage-region",
|
||
isBackground ? "uml-stage-region-bg" : "uml-stage-region-outline",
|
||
);
|
||
rect.classList.toggle("uml-stage-region-active", index === stageIndex);
|
||
rect.classList.toggle("uml-stage-region-completed", isCompleted);
|
||
if (duplicateIndex === 0) {
|
||
rect.setAttribute("role", "button");
|
||
rect.setAttribute("tabindex", "0");
|
||
rect.setAttribute("aria-label", `Pokaż etap ${item.label}`);
|
||
}
|
||
});
|
||
});
|
||
}, [asset.alt, progress, stageIndex, stages, svgMarkup]);
|
||
|
||
const selectStageAtPoint = (clientX: number, clientY: number) => {
|
||
const svg = canvasRef.current?.querySelector("svg");
|
||
if (!svg) return;
|
||
const hit = Array.from(
|
||
svg.querySelectorAll<SVGRectElement>(".uml-stage-region-bg[data-uml-stage]"),
|
||
)
|
||
.filter((rect) => {
|
||
const bounds = rect.getBoundingClientRect();
|
||
return (
|
||
clientX >= bounds.left &&
|
||
clientX <= bounds.right &&
|
||
clientY >= bounds.top &&
|
||
clientY <= bounds.bottom
|
||
);
|
||
})
|
||
.sort((left, right) => {
|
||
const a = left.getBoundingClientRect();
|
||
const b = right.getBoundingClientRect();
|
||
return a.width * a.height - b.width * b.height;
|
||
})[0];
|
||
const next = Number(hit?.dataset.umlStage);
|
||
if (Number.isInteger(next)) setStageIndex(next);
|
||
};
|
||
|
||
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
const target = event.target as SVGRectElement;
|
||
const next = Number(target.dataset?.umlStage);
|
||
if (!Number.isInteger(next)) return;
|
||
event.preventDefault();
|
||
setStageIndex(next);
|
||
};
|
||
const stage = stages[stageIndex];
|
||
return (
|
||
<div className="uml-react">
|
||
<header>
|
||
<div>
|
||
<small>KANONICZNY DIAGRAM UML · PLANTUML</small>
|
||
<h3>Reset i dwa przydziały liniowe</h3>
|
||
</div>
|
||
<div className="uml-actions">
|
||
{stages.map((item, index) => (
|
||
<button
|
||
key={item.id}
|
||
aria-pressed={stageIndex === index}
|
||
data-completed={
|
||
item.progress_id &&
|
||
progress?.items?.[item.progress_id]?.status === "approved"
|
||
? "true"
|
||
: "false"
|
||
}
|
||
onClick={() => setStageIndex(index)}
|
||
>
|
||
{item.progress_id &&
|
||
progress?.items?.[item.progress_id]?.status === "approved" && (
|
||
<span aria-hidden="true">✓ </span>
|
||
)}
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</header>
|
||
<div
|
||
ref={canvasRef}
|
||
className="uml-canvas"
|
||
onClick={(event) => selectStageAtPoint(event.clientX, event.clientY)}
|
||
onKeyDown={handleSvgKeyDown}
|
||
>
|
||
{svgMarkup ? (
|
||
<div
|
||
className="uml-svg-host"
|
||
// The SVG is a generated, same-origin PlantUML asset; scripts and
|
||
// foreignObject nodes are removed before it reaches the DOM.
|
||
dangerouslySetInnerHTML={{ __html: svgMarkup }}
|
||
/>
|
||
) : (
|
||
<img src={asset.href} alt={asset.alt} />
|
||
)}
|
||
{svgFailed && (
|
||
<span className="uml-inline-warning">
|
||
Widok statyczny — nie udało się wczytać interaktywnego SVG.
|
||
</span>
|
||
)}
|
||
</div>
|
||
{stage && (
|
||
<aside className="uml-stage">
|
||
<strong>{stage.label}</strong>
|
||
<span>{stage.description}</span>
|
||
{stage.code_ref && <code>{stage.code_ref}</code>}
|
||
</aside>
|
||
)}
|
||
{stage?.progress_id && (
|
||
<footer className="uml-approval">
|
||
<span>
|
||
{progress?.items?.[stage.progress_id]?.status === "approved"
|
||
? "Ten stan jest zatwierdzony i zapisany w JSON-ie zajęć."
|
||
: "Kursor wybiera stan; zatwierdzenie zapisuje go w JSON-ie zajęć."}
|
||
</span>
|
||
<button
|
||
disabled={!progress || approvalBusy}
|
||
onClick={async () => {
|
||
if (!stage.progress_id || !progress) return;
|
||
const completed =
|
||
progress.items?.[stage.progress_id]?.status === "approved";
|
||
setApprovalBusy(true);
|
||
try {
|
||
await onSetStatus(
|
||
stage.progress_id,
|
||
completed ? "pending" : "approved",
|
||
);
|
||
} finally {
|
||
setApprovalBusy(false);
|
||
}
|
||
}}
|
||
>
|
||
{approvalBusy
|
||
? "Zapisywanie…"
|
||
: progress?.items?.[stage.progress_id]?.status === "approved"
|
||
? "Cofnij zatwierdzenie"
|
||
: "Zatwierdź stan"}
|
||
</button>
|
||
</footer>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SnapshotInteractiveUmlSequence({
|
||
config,
|
||
asset,
|
||
progress,
|
||
onSetStatus,
|
||
}: {
|
||
config: InteractiveAsset;
|
||
asset: Asset;
|
||
progress: Progress | null;
|
||
onSetStatus: SetProgressStatus;
|
||
}) {
|
||
const phases = config.phases ?? [];
|
||
const entries = phases.flatMap((phase) =>
|
||
phase.steps.map((step) => ({ phase, step })),
|
||
);
|
||
const storageKey = `${config.storage_key ?? asset.label}-uml-step`;
|
||
const focusStorageKey = `${config.storage_key ?? asset.label}-navigation-level`;
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
const canvasRef = useRef<HTMLDivElement>(null);
|
||
const handledRevealRevisionRef = useRef(0);
|
||
const activateOnChangeRef = useRef(false);
|
||
const focusOnChangeRef = useRef<NavigationLevel | null>(null);
|
||
const persistOnChangeRef = useRef(true);
|
||
const [svgMarkup, setSvgMarkup] = useState("");
|
||
const [svgFailed, setSvgFailed] = useState(false);
|
||
const [revealRevision, setRevealRevision] = useState(0);
|
||
const [stepIndex, setStepIndex] = useState(() =>
|
||
Math.max(
|
||
0,
|
||
Math.min(
|
||
entries.length - 1,
|
||
Number(localStorage.getItem(storageKey) ?? 0),
|
||
),
|
||
),
|
||
);
|
||
const [focusLevel, setFocusLevel] = useState<NavigationLevel>(() => {
|
||
const saved = localStorage.getItem(focusStorageKey);
|
||
return saved === "card" || saved === "task" || saved === "block" || saved === "phase"
|
||
|| saved === "step" || saved === "snapshot"
|
||
? saved
|
||
: "step";
|
||
});
|
||
|
||
useEffect(
|
||
() => localStorage.setItem(storageKey, String(stepIndex)),
|
||
[stepIndex, storageKey],
|
||
);
|
||
useEffect(
|
||
() => localStorage.setItem(focusStorageKey, focusLevel),
|
||
[focusLevel, focusStorageKey],
|
||
);
|
||
useEffect(() => {
|
||
let current = true;
|
||
setSvgFailed(false);
|
||
fetch(asset.href, { cache: "no-store" })
|
||
.then((response) => {
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
return response.text();
|
||
})
|
||
.then((markup) => {
|
||
const document = new DOMParser().parseFromString(
|
||
markup,
|
||
"image/svg+xml",
|
||
);
|
||
const svg = document.documentElement;
|
||
if (
|
||
svg.nodeName.toLowerCase() !== "svg" ||
|
||
document.querySelector("parsererror")
|
||
) {
|
||
throw new Error("Niepoprawny SVG");
|
||
}
|
||
document
|
||
.querySelectorAll("script, foreignObject")
|
||
.forEach((node) => node.remove());
|
||
svg.removeAttribute("width");
|
||
svg.removeAttribute("height");
|
||
svg.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
||
if (current) setSvgMarkup(svg.outerHTML);
|
||
})
|
||
.catch(() => {
|
||
if (current) setSvgFailed(true);
|
||
});
|
||
return () => {
|
||
current = false;
|
||
};
|
||
}, [asset.href]);
|
||
|
||
useEffect(() => {
|
||
const svg = canvasRef.current?.querySelector("svg");
|
||
if (!svg) return;
|
||
svg.setAttribute("role", "img");
|
||
svg.setAttribute("aria-label", asset.alt);
|
||
svg.querySelectorAll(".uml-step-hit").forEach((node) => node.remove());
|
||
svg
|
||
.querySelectorAll<SVGElement>("[data-uml-step-element]")
|
||
.forEach((element) => {
|
||
element.classList.remove(
|
||
"uml-step-element",
|
||
"uml-step-element-active",
|
||
"uml-step-element-completed",
|
||
);
|
||
element.removeAttribute("data-uml-step-element");
|
||
});
|
||
|
||
const texts = Array.from(svg.querySelectorAll<SVGTextElement>("text"));
|
||
const lines = Array.from(svg.querySelectorAll<SVGLineElement>("line"));
|
||
const polygons = Array.from(svg.querySelectorAll<SVGPolygonElement>("polygon"));
|
||
const viewBox = svg.viewBox.baseVal;
|
||
const hitX = viewBox?.width ? viewBox.x : 0;
|
||
const hitWidth = viewBox?.width || 1200;
|
||
|
||
entries.forEach(({ step }, index) => {
|
||
const svgLabel = step.svg_label ?? String(step.number).padStart(2, "0");
|
||
const numberText = texts.find(
|
||
(text) => (text.textContent ?? "").trim() === svgLabel,
|
||
);
|
||
const labelY = Number(numberText?.getAttribute("y"));
|
||
if (!numberText || !Number.isFinite(labelY)) return;
|
||
const arrowY = labelY + 5;
|
||
const completed = Boolean(
|
||
step.progress_id &&
|
||
progress?.items?.[step.progress_id]?.status === "approved",
|
||
);
|
||
const related: SVGElement[] = texts.filter(
|
||
(text) => Math.abs(Number(text.getAttribute("y")) - labelY) < 0.8,
|
||
);
|
||
related.push(
|
||
...lines.filter((line) => {
|
||
const y1 = Number(line.getAttribute("y1"));
|
||
const y2 = Number(line.getAttribute("y2"));
|
||
return (
|
||
Number.isFinite(y1) &&
|
||
Number.isFinite(y2) &&
|
||
Math.abs(y1 - y2) < 0.8 &&
|
||
Math.abs((y1 + y2) / 2 - arrowY) <= 14
|
||
);
|
||
}),
|
||
);
|
||
related.push(
|
||
...polygons.filter((polygon) => {
|
||
const points = (polygon.getAttribute("points") ?? "")
|
||
.trim()
|
||
.split(/\s+/)
|
||
.map((point) => Number(point.split(",")[1]))
|
||
.filter(Number.isFinite);
|
||
if (!points.length) return false;
|
||
const minY = Math.min(...points);
|
||
const maxY = Math.max(...points);
|
||
return maxY - minY <= 16 && Math.abs((minY + maxY) / 2 - arrowY) <= 14;
|
||
}),
|
||
);
|
||
related.forEach((element) => {
|
||
element.dataset.umlStepElement = String(index);
|
||
element.classList.add("uml-step-element");
|
||
element.classList.toggle("uml-step-element-active", index === stepIndex);
|
||
element.classList.toggle("uml-step-element-completed", completed);
|
||
});
|
||
|
||
const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||
hit.setAttribute("x", String(hitX));
|
||
hit.setAttribute("y", String(labelY - 12));
|
||
hit.setAttribute("width", String(hitWidth));
|
||
hit.setAttribute("height", "31");
|
||
hit.setAttribute("fill", "transparent");
|
||
hit.setAttribute("pointer-events", "all");
|
||
hit.setAttribute("role", "button");
|
||
hit.setAttribute("tabindex", "0");
|
||
hit.setAttribute(
|
||
"aria-label",
|
||
`Pokaż krok ${String(step.number).padStart(2, "0")}: ${step.label}`,
|
||
);
|
||
hit.dataset.umlStep = String(index);
|
||
hit.classList.add("uml-step-hit");
|
||
hit.classList.toggle("uml-step-hit-active", focusLevel !== "card" && index === stepIndex);
|
||
hit.classList.toggle("uml-step-hit-completed", completed);
|
||
svg.appendChild(hit);
|
||
});
|
||
}, [asset.alt, entries, progress, stepIndex, svgMarkup]);
|
||
|
||
useEffect(() => {
|
||
if (
|
||
revealRevision === 0
|
||
|| handledRevealRevisionRef.current === revealRevision
|
||
) return;
|
||
|
||
let cancelled = false;
|
||
let correctionFrame = 0;
|
||
const reveal = (pass: number) => {
|
||
if (cancelled) return;
|
||
const target = canvasRef.current?.querySelector<SVGRectElement>(
|
||
`.uml-step-hit[data-uml-step="${stepIndex}"]`,
|
||
);
|
||
if (!target) return;
|
||
const chrome = document.querySelector<HTMLElement>(".viewer-chrome");
|
||
if (chrome?.classList.contains("is-nvim-fit")) {
|
||
handledRevealRevisionRef.current = revealRevision;
|
||
return;
|
||
}
|
||
const windowViewport = chromeAwareWindowViewport(target);
|
||
if (!windowViewport) {
|
||
handledRevealRevisionRef.current = revealRevision;
|
||
return;
|
||
}
|
||
const viewport = visibleVerticalViewport(target);
|
||
if (!viewport) {
|
||
const pageMovement = bringTargetSheetIntoWindow(target, windowViewport);
|
||
const fallbackDelta = pageMovement === 0
|
||
? umlScrolloffDelta(target, windowViewport)
|
||
: 0;
|
||
const fallbackMovement = pageMovement === 0
|
||
? applyVerticalScrollDelta(target, fallbackDelta)
|
||
: pageMovement;
|
||
if (
|
||
pass < UML_SCROLLOFF_MAX_PASSES - 1
|
||
&& Math.abs(fallbackMovement) > UML_SCROLLOFF_EPSILON_PX
|
||
) {
|
||
correctionFrame = window.requestAnimationFrame(() => reveal(pass + 1));
|
||
return;
|
||
}
|
||
handledRevealRevisionRef.current = revealRevision;
|
||
return;
|
||
}
|
||
const delta = umlScrolloffDelta(target, viewport);
|
||
if (Math.abs(delta) <= UML_SCROLLOFF_EPSILON_PX) {
|
||
handledRevealRevisionRef.current = revealRevision;
|
||
return;
|
||
}
|
||
const applied = applyVerticalScrollDelta(target, delta);
|
||
if (
|
||
pass < UML_SCROLLOFF_MAX_PASSES - 1
|
||
&& Math.abs(applied) > UML_SCROLLOFF_EPSILON_PX
|
||
) {
|
||
correctionFrame = window.requestAnimationFrame(() => reveal(pass + 1));
|
||
return;
|
||
}
|
||
handledRevealRevisionRef.current = revealRevision;
|
||
};
|
||
const frame = window.requestAnimationFrame(() => reveal(0));
|
||
return () => {
|
||
cancelled = true;
|
||
window.cancelAnimationFrame(frame);
|
||
window.cancelAnimationFrame(correctionFrame);
|
||
};
|
||
}, [revealRevision, stepIndex, svgMarkup]);
|
||
|
||
const publishEntry = (
|
||
entry: { phase: UmlSequencePhase; step: UmlSequenceStep },
|
||
activate: boolean,
|
||
level: NavigationLevel = focusLevel,
|
||
persist = true,
|
||
) => {
|
||
const detail: ActiveNvimStep = {
|
||
task: config.task ?? null,
|
||
block: config.block ?? null,
|
||
phase: { id: entry.phase.id, label: entry.phase.label },
|
||
step: entry.step,
|
||
focusLevel: level,
|
||
activate: activate && document.documentElement.dataset.nvimSyncMode === "on",
|
||
};
|
||
const dispatch = () => {
|
||
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", { detail }));
|
||
};
|
||
if (!persist) {
|
||
dispatch();
|
||
return;
|
||
}
|
||
const save = fetch("/api/navigation/current", {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
task: detail.task,
|
||
block: detail.block,
|
||
phase: detail.phase,
|
||
step: {
|
||
id: detail.step.id,
|
||
number: detail.step.number,
|
||
label: detail.step.label,
|
||
},
|
||
snapshot_ref: detail.step.snapshot_ref ?? null,
|
||
focus_level: detail.focusLevel,
|
||
sync_requested: Boolean(detail.activate),
|
||
actor: document.documentElement.dataset.cardClientId ?? "browser",
|
||
}),
|
||
});
|
||
if (detail.activate) {
|
||
// Activation must be audited against the newly selected step. In a static
|
||
// export the request fails, but the local interactive model still works.
|
||
void save.then(dispatch, dispatch);
|
||
} else {
|
||
dispatch();
|
||
void save.catch(() => undefined);
|
||
}
|
||
};
|
||
const selectIndex = (
|
||
next: number,
|
||
activate = false,
|
||
level: NavigationLevel = focusLevel,
|
||
persist = true,
|
||
) => {
|
||
const bounded = Math.max(0, Math.min(entries.length - 1, next));
|
||
const entry = entries[bounded];
|
||
if (!entry) return;
|
||
setFocusLevel(level);
|
||
if (bounded === stepIndex) {
|
||
publishEntry(entry, activate, level, persist);
|
||
return;
|
||
}
|
||
setRevealRevision(current => current + 1);
|
||
activateOnChangeRef.current = activate;
|
||
focusOnChangeRef.current = level;
|
||
persistOnChangeRef.current = persist;
|
||
setStepIndex(bounded);
|
||
};
|
||
const selectStepAtPoint = (clientX: number, clientY: number) => {
|
||
const hits = Array.from(
|
||
canvasRef.current?.querySelectorAll<SVGRectElement>(
|
||
".uml-step-hit[data-uml-step]",
|
||
) ?? [],
|
||
)
|
||
.filter((hit) => {
|
||
const bounds = hit.getBoundingClientRect();
|
||
return (
|
||
clientX >= bounds.left &&
|
||
clientX <= bounds.right &&
|
||
clientY >= bounds.top &&
|
||
clientY <= bounds.bottom
|
||
);
|
||
})
|
||
.sort((left, right) => {
|
||
const a = left.getBoundingClientRect();
|
||
const b = right.getBoundingClientRect();
|
||
return (
|
||
Math.abs(clientY - (a.top + a.bottom) / 2) -
|
||
Math.abs(clientY - (b.top + b.bottom) / 2)
|
||
);
|
||
});
|
||
const next = Number(hits[0]?.dataset.umlStep);
|
||
if (Number.isInteger(next)) selectIndex(next, false, "step");
|
||
};
|
||
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||
if (event.key !== "Enter" && event.key !== " ") return;
|
||
const target = event.target as SVGRectElement;
|
||
const next = Number(target.dataset?.umlStep);
|
||
if (!Number.isInteger(next)) return;
|
||
event.preventDefault();
|
||
selectIndex(next, event.key === "Enter", "step");
|
||
};
|
||
const selectPhase = (phase: UmlSequencePhase) => {
|
||
const next = entries.findIndex((entry) => entry.phase.id === phase.id);
|
||
if (next >= 0) selectIndex(next, false, "phase");
|
||
};
|
||
|
||
const active = entries[stepIndex];
|
||
useEffect(() => {
|
||
if (!active) return;
|
||
publishEntry(
|
||
active,
|
||
activateOnChangeRef.current,
|
||
focusOnChangeRef.current ?? focusLevel,
|
||
persistOnChangeRef.current,
|
||
);
|
||
activateOnChangeRef.current = false;
|
||
focusOnChangeRef.current = null;
|
||
persistOnChangeRef.current = true;
|
||
}, [active?.phase.id, active?.step.id, config.block]);
|
||
|
||
useEffect(() => {
|
||
const applyCommand = (event: Event) => {
|
||
const command = (event as CustomEvent<ApiNavigation>).detail;
|
||
if (!command) return;
|
||
if (config.task?.id && command.task.id !== config.task.id) return;
|
||
if (config.block?.id && command.block.id !== config.block.id) return;
|
||
const next = entries.findIndex(entry =>
|
||
entry.phase.id === command.phase.id && entry.step.id === command.step.id,
|
||
);
|
||
if (next < 0) return;
|
||
// Remote navigation only moves the teaching cursor. The producer that
|
||
// requested a replay owns it; reflecting state must never replay twice.
|
||
selectIndex(next, false, command.focus_level, false);
|
||
};
|
||
window.addEventListener("stem:navigation-command", applyCommand);
|
||
return () => window.removeEventListener("stem:navigation-command", applyCommand);
|
||
}, [config.block?.id, config.task?.id, entries, focusLevel, stepIndex]);
|
||
|
||
useEffect(() => {
|
||
const navigate = (event: KeyboardEvent) => {
|
||
if (event.metaKey) return;
|
||
const target = event.target as HTMLElement | null;
|
||
if (target?.closest("input, textarea, select, [contenteditable='true']")) return;
|
||
const root = rootRef.current;
|
||
if (!root) return;
|
||
const diagrams = Array.from(
|
||
document.querySelectorAll<HTMLElement>("[data-stem-nav='uml-block']"),
|
||
);
|
||
const viewportCenter = window.innerHeight / 2;
|
||
const nearest = diagrams.sort((left, right) => {
|
||
const a = left.getBoundingClientRect();
|
||
const b = right.getBoundingClientRect();
|
||
return Math.abs((a.top + a.bottom) / 2 - viewportCenter)
|
||
- Math.abs((b.top + b.bottom) / 2 - viewportCenter);
|
||
})[0];
|
||
if (nearest !== root) return;
|
||
|
||
if (event.key === "Enter" && event.ctrlKey && !event.altKey && !event.shiftKey) {
|
||
if (!active?.step.progress_id || !progress) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const approved = progress.items?.[active.step.progress_id]?.status === "approved";
|
||
void onSetStatus(active.step.progress_id, approved ? "pending" : "approved");
|
||
return;
|
||
}
|
||
|
||
if (event.key === "Enter" && !event.altKey && !event.shiftKey) {
|
||
if (!active) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
publishEntry(active, true, focusLevel);
|
||
return;
|
||
}
|
||
|
||
const hierarchy: NavigationLevel[] = ["card", "task", "block", "phase", "step", "snapshot"];
|
||
if ((event.key === "ArrowLeft" || event.key === "ArrowRight" || event.key === "Escape")
|
||
&& !event.altKey && !event.ctrlKey && !event.shiftKey) {
|
||
const direction = event.key === "ArrowRight" ? 1 : -1;
|
||
const nextLevel = hierarchy[Math.max(
|
||
0,
|
||
Math.min(hierarchy.length - 1, hierarchy.indexOf(focusLevel) + direction),
|
||
)];
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
setFocusLevel(nextLevel);
|
||
if (active) publishEntry(active, false, nextLevel);
|
||
return;
|
||
}
|
||
|
||
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
||
if (focusLevel === "card" && !event.altKey && !event.ctrlKey && !event.shiftKey) return;
|
||
const direction = event.key === "ArrowUp" ? -1 : 1;
|
||
let level: NavigationLevel = focusLevel;
|
||
if (event.altKey && event.shiftKey && !event.ctrlKey) level = "snapshot";
|
||
else if (event.ctrlKey && event.shiftKey && !event.altKey) level = "step";
|
||
else if (event.altKey && !event.ctrlKey && !event.shiftKey) level = "task";
|
||
else if (event.ctrlKey && !event.altKey && !event.shiftKey) level = "block";
|
||
else if (event.shiftKey && !event.altKey && !event.ctrlKey) level = "phase";
|
||
else if (event.altKey || event.ctrlKey || event.shiftKey) return;
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
void fetch("/api/navigation/move", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
level,
|
||
delta: direction,
|
||
sync_requested: false,
|
||
actor: document.documentElement.dataset.cardClientId ?? "browser",
|
||
}),
|
||
})
|
||
.then(response => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
|
||
.then(payload => {
|
||
if (payload.current) {
|
||
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
|
||
detail: payload.current,
|
||
}));
|
||
}
|
||
})
|
||
.catch(() => undefined);
|
||
};
|
||
window.addEventListener("keydown", navigate);
|
||
return () => window.removeEventListener("keydown", navigate);
|
||
}, [active, entries, focusLevel, onSetStatus, progress]);
|
||
if (!active) return null;
|
||
const { phase, step } = active;
|
||
const phaseEntries = entries.filter((entry) => entry.phase.id === phase.id);
|
||
|
||
return (
|
||
<div
|
||
ref={rootRef}
|
||
className="uml-react uml-snapshot-react"
|
||
data-stem-nav="uml-block"
|
||
data-task-id={config.task?.id}
|
||
data-block-id={config.block?.id}
|
||
data-navigation-level={focusLevel}
|
||
>
|
||
<header className="uml-snapshot-header">
|
||
<div>
|
||
<small>BLOCK · {config.block?.label ?? "PRZEPŁYW UML"}</small>
|
||
<h3>{config.title ?? config.block?.label ?? asset.caption}</h3>
|
||
{config.block?.description && <p>{config.block.description}</p>}
|
||
</div>
|
||
<div className="uml-phase-actions" aria-label="Fazy diagramu">
|
||
{phases.map((item) => (
|
||
<button
|
||
key={item.id}
|
||
aria-pressed={phase.id === item.id}
|
||
onClick={() => selectPhase(item)}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</header>
|
||
<div className="uml-step-actions" aria-label={`Kroki fazy ${phase.label}`}>
|
||
<span>{phase.label}</span>
|
||
{phaseEntries.map((entry) => {
|
||
const index = entries.indexOf(entry);
|
||
const isDone = Boolean(
|
||
entry.step.progress_id &&
|
||
progress?.items?.[entry.step.progress_id]?.status === "approved",
|
||
);
|
||
return (
|
||
<button
|
||
key={entry.step.id}
|
||
aria-label={`Krok ${entry.step.number}: ${entry.step.label}`}
|
||
aria-pressed={focusLevel !== "card" && index === stepIndex}
|
||
data-completed={isDone ? "true" : "false"}
|
||
onClick={() => selectIndex(index)}
|
||
>
|
||
{String(entry.step.number).padStart(2, "0")}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="uml-snapshot-layout">
|
||
<div
|
||
ref={canvasRef}
|
||
className="uml-canvas uml-step-canvas"
|
||
onClick={(event) => selectStepAtPoint(event.clientX, event.clientY)}
|
||
onKeyDown={handleSvgKeyDown}
|
||
>
|
||
{svgMarkup ? (
|
||
<div
|
||
className="uml-svg-host"
|
||
dangerouslySetInnerHTML={{ __html: svgMarkup }}
|
||
/>
|
||
) : (
|
||
<img src={asset.href} alt={asset.alt} />
|
||
)}
|
||
{svgFailed && (
|
||
<span className="uml-inline-warning">
|
||
Widok statyczny — wybierz krok przyciskiem nad diagramem.
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InteractiveUmlSequence(props: {
|
||
config: InteractiveAsset;
|
||
asset: Asset;
|
||
progress: Progress | null;
|
||
onSetStatus: SetProgressStatus;
|
||
}) {
|
||
return props.config.phases?.length ? (
|
||
<SnapshotInteractiveUmlSequence {...props} />
|
||
) : (
|
||
<LegacyInteractiveUmlStages {...props} />
|
||
);
|
||
}
|
||
|
||
function AssetFigure({
|
||
asset,
|
||
number,
|
||
progress,
|
||
onSetStatus,
|
||
}: {
|
||
asset: Asset;
|
||
number: number;
|
||
progress: Progress | null;
|
||
onSetStatus: SetProgressStatus;
|
||
}) {
|
||
const interactiveKind = asset.interactive?.kind;
|
||
const isInteractive = Boolean(interactiveKind);
|
||
return (
|
||
<figure
|
||
className={`card-asset asset-${asset.kind} ${isInteractive ? "asset-interactive" : ""}`}
|
||
id={asset.label.replace(":", "-")}
|
||
>
|
||
{interactiveKind === "uml-sequence" ? (
|
||
<InteractiveUmlSequence
|
||
config={asset.interactive!}
|
||
asset={asset}
|
||
progress={progress}
|
||
onSetStatus={onSetStatus}
|
||
/>
|
||
) : interactiveKind === "allocator-memory-flow" ? (
|
||
<>
|
||
<InteractiveMemoryFlow
|
||
config={asset.interactive!}
|
||
title={asset.caption}
|
||
/>
|
||
<img
|
||
className="asset-static-fallback"
|
||
src={asset.href}
|
||
alt={asset.alt}
|
||
/>
|
||
</>
|
||
) : (
|
||
<a href={asset.href} target="_blank" rel="noopener">
|
||
<img
|
||
src={asset.href}
|
||
alt={asset.alt}
|
||
loading="lazy"
|
||
style={{ width: `${asset.width * 100}%` }}
|
||
/>
|
||
</a>
|
||
)}
|
||
{asset.source_href && (
|
||
<a
|
||
className="asset-source-link"
|
||
href={asset.source_href}
|
||
target="_blank"
|
||
rel="noopener"
|
||
>
|
||
Źródło PlantUML
|
||
</a>
|
||
)}
|
||
<figcaption>
|
||
Rys. {number}. {asset.caption}
|
||
</figcaption>
|
||
</figure>
|
||
);
|
||
}
|
||
|
||
function StepMap({
|
||
steps,
|
||
progress,
|
||
onCycle,
|
||
}: {
|
||
steps: Section["steps"];
|
||
progress: Progress | null;
|
||
onCycle: (id: string, status: Status) => void;
|
||
}) {
|
||
if (!steps.length) return null;
|
||
const labels: Record<Status, string> = {
|
||
pending: "○ niezatwierdzony",
|
||
approved: "● zatwierdzony",
|
||
};
|
||
return (
|
||
<div className="step-map">
|
||
{steps.map((step) => {
|
||
const status: Status =
|
||
progress?.items?.[step.id]?.status ?? "pending";
|
||
return (
|
||
<div
|
||
className={`step-row ${status === "approved" ? "lesson-progress-completed" : ""}`}
|
||
key={step.id}
|
||
>
|
||
<button
|
||
className="lesson-progress-control"
|
||
data-status={status}
|
||
onClick={() => onCycle(step.id, status)}
|
||
disabled={!progress}
|
||
>
|
||
{labels[status]}
|
||
</button>
|
||
<span className="step-title">{step.title}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function SectionPage({
|
||
section,
|
||
progress,
|
||
onCycle,
|
||
onSetStatus,
|
||
figureOffset,
|
||
}: {
|
||
section: Section;
|
||
progress: Progress | null;
|
||
onCycle: (id: string, status: Status) => void;
|
||
onSetStatus: SetProgressStatus;
|
||
figureOffset: number;
|
||
}) {
|
||
return (
|
||
<Sheet
|
||
header={section.header_html}
|
||
left={section.left_margin_html}
|
||
right={section.right_margin_html}
|
||
className="section-sheet"
|
||
>
|
||
<div id={section.id} className="section-anchor" />
|
||
{section.show_heading && (
|
||
<h2 className="section-heading">
|
||
<span className="section-index">{section.index}</span>
|
||
<span className="section-title">{section.title}</span>
|
||
</h2>
|
||
)}
|
||
<StepMap steps={section.steps} progress={progress} onCycle={onCycle} />
|
||
{section.content_html && <Html html={section.content_html} />}
|
||
<div className="tasks">
|
||
{section.tasks.map((task) => (
|
||
<TaskFlow key={task.ref} task={task} />
|
||
))}
|
||
</div>
|
||
{section.assets.map((asset, index) => (
|
||
<AssetFigure
|
||
key={asset.label}
|
||
asset={asset}
|
||
number={figureOffset + index + 1}
|
||
progress={progress}
|
||
onSetStatus={onSetStatus}
|
||
/>
|
||
))}
|
||
{section.tasks.map(
|
||
(task) =>
|
||
task.conclusion_html && (
|
||
<aside className="task-conclusion" key={`${task.ref}-conclusion`}>
|
||
<strong>WNIOSEK</strong>
|
||
<Html html={task.conclusion_html} />
|
||
</aside>
|
||
),
|
||
)}
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function ProgressDock({
|
||
progress,
|
||
error,
|
||
}: {
|
||
progress: Progress | null;
|
||
error: string;
|
||
}) {
|
||
if (!progress?.summary && !error) return null;
|
||
const counts = progress?.summary.counts;
|
||
return (
|
||
<aside className="lesson-progress-dock">
|
||
<strong>Przebieg zajęć</strong>
|
||
{counts && (
|
||
<p>
|
||
Zatwierdzone: {counts.approved}/{progress!.summary.total}
|
||
</p>
|
||
)}
|
||
{error && <p className="lesson-progress-error">{error}</p>}
|
||
<a href="/api/report/teams.md" target="_blank" rel="noopener">
|
||
Otwórz raport do Teams
|
||
</a>
|
||
<br />
|
||
<a href="/api/report/lesson.html" target="_blank" rel="noopener">
|
||
Otwórz ślad lekcji ze zrzutami
|
||
</a>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||
if (!open) return null;
|
||
const rows = [
|
||
["Alt + ↑ / ↓", "poprzedni / następny TASK"],
|
||
["Ctrl + ↑ / ↓", "poprzedni / następny BLOCK"],
|
||
["Shift + ↑ / ↓", "poprzednia / następna PHASE"],
|
||
["Ctrl + Shift + ↑ / ↓", "poprzedni / następny STEP"],
|
||
["Alt + Shift + ↑ / ↓", "poprzedni / następny unikalny SNAPSHOT"],
|
||
["↑ / ↓", "poprzedni / następny element aktywnego poziomu"],
|
||
["→", "zejdź poziom niżej"],
|
||
["← / Esc", "wróć poziom wyżej"],
|
||
["Enter", "wybierz element; przy SYNC ON odtwórz stan"],
|
||
["F1", "reset aktualnego celu Neovima/kontenera"],
|
||
["F2", "synchronizuj cel z kursorem drzewa UML"],
|
||
["Ctrl + `", "maksymalizuj pane, dopasuj siatkę i odśwież Neovima"],
|
||
["Ctrl + Enter", "zatwierdź lub cofnij zatwierdzenie kroku"],
|
||
["Alt + 1", "pokaż lub ukryj panel Neovima"],
|
||
["Alt + 2", "przełącz SYNC OFF / SYNC ON"],
|
||
["Alt + 3", "uzbrój lub rozbrój sterowanie Neovimem"],
|
||
["Alt + 4", "wysoki panel 90% / zapisana wysokość normalna"],
|
||
["Alt + 5", "dopasuj cały ekran Neovima; wyłącz ręczny suwak"],
|
||
["Alt + 6", "przypnij lub ukryj model alokatora"],
|
||
["Alt + 3, potem najedź", "przekaż klawiaturę i mysz do aktywnej sesji"],
|
||
["Alt + W", "prefiks poleceń okien Neovima (<C-W>)"],
|
||
["Alt + strzałka", "przejdź do sąsiedniego okna Neovima"],
|
||
["F12", "pokaż lub ukryj ten skorowidz"],
|
||
];
|
||
return (
|
||
<div className="shortcut-help-backdrop" role="presentation" onMouseDown={onClose}>
|
||
<section
|
||
className="shortcut-help"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="shortcut-help-title"
|
||
onMouseDown={event => event.stopPropagation()}
|
||
>
|
||
<header>
|
||
<div>
|
||
<small>F12 · NAWIGACJA DYDAKTYCZNA</small>
|
||
<h2 id="shortcut-help-title">CARD → TASK → BLOCK → PHASE → STEP → SNAPSHOT</h2>
|
||
</div>
|
||
<button onClick={onClose} aria-label="Zamknij skorowidz">×</button>
|
||
</header>
|
||
<dl>
|
||
{rows.map(([keys, description]) => (
|
||
<div key={keys}>
|
||
<dt><kbd>{keys}</kbd></dt>
|
||
<dd>{description}</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
<p>
|
||
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>
|
||
);
|
||
}
|
||
|
||
function App({ model }: { model: CardModel }) {
|
||
const viewerChromeRef = useRef<HTMLDivElement>(null);
|
||
const clientIdRef = useRef(
|
||
`browser-${globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)}`,
|
||
);
|
||
const viewerRevisionRef = useRef(0);
|
||
const navigationRevisionRef = useRef(0);
|
||
const applyingRemoteScrollRef = useRef(false);
|
||
const pendingRemoteNavigationReportRef = useRef(false);
|
||
const remoteScrollFrameRef = useRef(0);
|
||
const remoteScrollTokenRef = useRef(0);
|
||
const shortcutStatusTimerRef = useRef(0);
|
||
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 [nvimControlMode, setNvimControlModeState] = useState(
|
||
() => localStorage.getItem("stem-card-nvim-sync-mode") === "true"
|
||
&& localStorage.getItem("stem-card-nvim-control-mode") === "true",
|
||
);
|
||
const [nvimExpanded, setNvimExpandedState] = useState(false);
|
||
const [nvimFit, setNvimFitState] = useState(false);
|
||
const [allocatorVisible, setAllocatorVisibleState] = useState(
|
||
() => localStorage.getItem("stem-card-allocator-visible") !== "false",
|
||
);
|
||
const [nvimSummary, setNvimSummary] = useState<NvimPanelSummary>({
|
||
state: "hidden",
|
||
syncMode: false,
|
||
syncReady: false,
|
||
controlMode: false,
|
||
controlEnabled: false,
|
||
});
|
||
const [progress, setProgress] = useState<Progress | null>(null);
|
||
const [progressError, setProgressError] = useState("");
|
||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||
const [shortcutStatus, setShortcutStatus] = useState("");
|
||
const showShortcutStatus = useCallback((message: string) => {
|
||
window.clearTimeout(shortcutStatusTimerRef.current);
|
||
setShortcutStatus(message);
|
||
shortcutStatusTimerRef.current = window.setTimeout(() => setShortcutStatus(""), 4200);
|
||
}, []);
|
||
const runWebFunctionKey = useCallback(async (key: "F1" | "F2") => {
|
||
showShortcutStatus(`${key} · wykonywanie…`);
|
||
try {
|
||
const endpoint = key === "F1" ? "/api/nvim/reset" : "/api/navigation/sync";
|
||
const response = await fetch(endpoint, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ actor: clientIdRef.current }),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.message ?? payload.error ?? `HTTP ${response.status}`);
|
||
if (key === "F2" && payload.current) {
|
||
navigationRevisionRef.current = Math.max(
|
||
navigationRevisionRef.current,
|
||
Number(payload.current.revision ?? 0),
|
||
);
|
||
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
|
||
detail: payload.current,
|
||
}));
|
||
showShortcutStatus(
|
||
`F2 · ${String(payload.current.step.number).padStart(2, "0")} ${payload.current.step.label} · READY`,
|
||
);
|
||
} else {
|
||
showShortcutStatus("F1 · reset przyjęty");
|
||
}
|
||
window.setTimeout(() => window.dispatchEvent(new Event("stem:nvim-refresh")), 120);
|
||
} catch (error) {
|
||
showShortcutStatus(`${key} · ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}, [showShortcutStatus]);
|
||
const runWebRedraw = useCallback(async () => {
|
||
showShortcutStatus("Ctrl+` · przeliczanie widoku…");
|
||
try {
|
||
const response = await fetch("/api/nvim/redraw", {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ actor: clientIdRef.current }),
|
||
});
|
||
const payload = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(payload.message ?? payload.error ?? `HTTP ${response.status}`);
|
||
const grid = payload.external_ui?.grid;
|
||
const dimensions = grid ? ` · ${grid.width}×${grid.height}` : "";
|
||
showShortcutStatus(`Ctrl+\` · widok odświeżony${dimensions}`);
|
||
window.dispatchEvent(new Event("stem:nvim-refresh"));
|
||
} catch (error) {
|
||
showShortcutStatus(`Ctrl+\` · ${error instanceof Error ? error.message : String(error)}`);
|
||
}
|
||
}, [showShortcutStatus]);
|
||
const publishViewer = useCallback(async (viewer: Record<string, unknown>) => {
|
||
try {
|
||
const response = await fetch("/api/viewer/state", {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ actor: clientIdRef.current, viewer }),
|
||
});
|
||
if (!response.ok) return;
|
||
const payload = await response.json();
|
||
viewerRevisionRef.current = Math.max(
|
||
viewerRevisionRef.current,
|
||
Number(payload.viewer?.revision ?? 0),
|
||
);
|
||
} catch {
|
||
// Statyczny HTML pozostaje użyteczny bez lokalnego API sterowania.
|
||
}
|
||
}, []);
|
||
const setScale = (value: number) => {
|
||
const next = Math.max(0.25, Math.min(3, value));
|
||
setScaleState(next);
|
||
void publishViewer({ scale: next });
|
||
};
|
||
useEffect(() => {
|
||
document.documentElement.style.setProperty(
|
||
"--viewer-canvas-scale",
|
||
scale.toFixed(3),
|
||
);
|
||
}, [scale]);
|
||
useEffect(() => {
|
||
document.documentElement.dataset.nvimSyncMode = nvimSyncMode ? "on" : "off";
|
||
}, [nvimSyncMode]);
|
||
useEffect(() => {
|
||
document.documentElement.dataset.cardClientId = clientIdRef.current;
|
||
}, []);
|
||
const setNvimVisible = (value: boolean) => {
|
||
setNvimVisibleState(value);
|
||
if (!value) {
|
||
setNvimExpandedState(false);
|
||
setNvimFitState(false);
|
||
}
|
||
localStorage.setItem("stem-card-nvim-visible", String(value));
|
||
void publishViewer({
|
||
nvim: {
|
||
visible: value,
|
||
...(value ? {} : { expanded: false, fit: false }),
|
||
},
|
||
});
|
||
};
|
||
const setNvimControlMode = (value: boolean) => {
|
||
const next = value && nvimSyncMode;
|
||
setNvimControlModeState(next);
|
||
localStorage.setItem("stem-card-nvim-control-mode", String(next));
|
||
void publishViewer({ nvim: { control: next } });
|
||
};
|
||
const setNvimSyncMode = (value: boolean) => {
|
||
setNvimSyncModeState(value);
|
||
localStorage.setItem("stem-card-nvim-sync-mode", String(value));
|
||
if (!value) {
|
||
setNvimControlModeState(false);
|
||
localStorage.setItem("stem-card-nvim-control-mode", "false");
|
||
}
|
||
void publishViewer({ nvim: { sync: value, control: value ? nvimControlMode : false } });
|
||
};
|
||
const setNvimExpanded = (value: boolean) => {
|
||
if (value) setNvimVisible(true);
|
||
if (value) setNvimFitState(false);
|
||
setNvimExpandedState(value);
|
||
void publishViewer({ nvim: { visible: value || nvimVisible, expanded: value, fit: false } });
|
||
};
|
||
const setNvimFit = (value: boolean) => {
|
||
if (value) {
|
||
setNvimVisible(true);
|
||
setNvimExpandedState(false);
|
||
}
|
||
setNvimFitState(value);
|
||
void publishViewer({ nvim: { visible: value || nvimVisible, fit: value, expanded: false } });
|
||
};
|
||
const setAllocatorVisible = (value: boolean) => {
|
||
setAllocatorVisibleState(value);
|
||
localStorage.setItem("stem-card-allocator-visible", String(value));
|
||
void publishViewer({ allocator: { visible: value } });
|
||
};
|
||
useEffect(() => {
|
||
let second = 0;
|
||
const first = window.requestAnimationFrame(() => {
|
||
second = window.requestAnimationFrame(() => {
|
||
window.dispatchEvent(new Event("stem:nvim-refresh"));
|
||
});
|
||
});
|
||
return () => {
|
||
window.cancelAnimationFrame(first);
|
||
window.cancelAnimationFrame(second);
|
||
};
|
||
}, [nvimExpanded, nvimFit]);
|
||
useEffect(() => {
|
||
fetch("/api/progress")
|
||
.then((response) =>
|
||
response.ok
|
||
? response.json()
|
||
: Promise.reject(new Error(`HTTP ${response.status}`)),
|
||
)
|
||
.then((data) => setProgress(data.progress))
|
||
.catch((error) =>
|
||
setProgressError(
|
||
`Postęp działa tylko przez serwer karty (${error.message}).`,
|
||
),
|
||
);
|
||
}, []);
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
let initialised = false;
|
||
const initialViewer = {
|
||
scale,
|
||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||
nvim: {
|
||
visible: nvimVisible,
|
||
sync: nvimSyncMode,
|
||
control: nvimControlMode,
|
||
expanded: nvimExpanded,
|
||
fit: nvimFit,
|
||
},
|
||
allocator: { visible: allocatorVisible },
|
||
};
|
||
const applyViewer = (viewer: ApiViewerState) => {
|
||
const fit = viewer.nvim.visible && viewer.nvim.fit;
|
||
const expanded = viewer.nvim.visible && !fit && viewer.nvim.expanded;
|
||
setScaleState(Math.max(0.25, Math.min(3, viewer.scale)));
|
||
setNvimVisibleState(viewer.nvim.visible);
|
||
setNvimSyncModeState(viewer.nvim.sync);
|
||
setNvimControlModeState(viewer.nvim.sync && viewer.nvim.control);
|
||
setNvimExpandedState(expanded);
|
||
setNvimFitState(fit);
|
||
const allocator = viewer.allocator?.visible ?? true;
|
||
setAllocatorVisibleState(allocator);
|
||
localStorage.setItem("stem-card-nvim-visible", String(viewer.nvim.visible));
|
||
localStorage.setItem("stem-card-nvim-sync-mode", String(viewer.nvim.sync));
|
||
localStorage.setItem(
|
||
"stem-card-nvim-control-mode",
|
||
String(viewer.nvim.sync && viewer.nvim.control),
|
||
);
|
||
localStorage.setItem("stem-card-allocator-visible", String(allocator));
|
||
const token = ++remoteScrollTokenRef.current;
|
||
applyingRemoteScrollRef.current = true;
|
||
window.cancelAnimationFrame(remoteScrollFrameRef.current);
|
||
remoteScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||
if (token !== remoteScrollTokenRef.current) return;
|
||
const root = documentScroller();
|
||
setScrollPositionInstant(root, {
|
||
left: viewer.scroll.x,
|
||
top: viewer.scroll.y,
|
||
});
|
||
|
||
const sheets = Array.from(
|
||
document.querySelectorAll<HTMLElement>(".paper > .sheet"),
|
||
);
|
||
const sheet = sheets[Math.max(
|
||
0,
|
||
Math.min(sheets.length - 1, viewer.scroll.page_index),
|
||
)];
|
||
const sheetContent = sheet?.querySelector<HTMLElement>(".sheet-content");
|
||
if (
|
||
sheetContent
|
||
&& Number.isFinite(viewer.scroll.sheet_scroll_top)
|
||
) {
|
||
setScrollPositionInstant(sheetContent, {
|
||
top: viewer.scroll.sheet_scroll_top ?? 0,
|
||
});
|
||
}
|
||
remoteScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||
if (token === remoteScrollTokenRef.current) {
|
||
applyingRemoteScrollRef.current = false;
|
||
if (pendingRemoteNavigationReportRef.current) {
|
||
pendingRemoteNavigationReportRef.current = false;
|
||
window.dispatchEvent(new Event("stem:viewer-scroll-settled"));
|
||
}
|
||
}
|
||
});
|
||
});
|
||
};
|
||
const poll = async () => {
|
||
try {
|
||
const response = await fetch("/api/control/state", { cache: "no-store" });
|
||
if (!response.ok || cancelled) return;
|
||
const payload = await response.json();
|
||
const viewer = payload.viewer as ApiViewerState;
|
||
if (!initialised) {
|
||
initialised = true;
|
||
if (Number(viewer?.revision ?? 0) === 0) void publishViewer(initialViewer);
|
||
}
|
||
if (viewer && viewer.revision > viewerRevisionRef.current) {
|
||
viewerRevisionRef.current = viewer.revision;
|
||
if (viewer.actor !== clientIdRef.current) applyViewer(viewer);
|
||
}
|
||
const navigation = payload.navigation as ApiNavigation | null;
|
||
if (navigation && navigation.revision > navigationRevisionRef.current) {
|
||
navigationRevisionRef.current = navigation.revision;
|
||
if (navigation.actor !== clientIdRef.current) {
|
||
if (applyingRemoteScrollRef.current) {
|
||
pendingRemoteNavigationReportRef.current = true;
|
||
}
|
||
window.dispatchEvent(new CustomEvent<ApiNavigation>("stem:navigation-command", {
|
||
detail: navigation,
|
||
}));
|
||
}
|
||
}
|
||
} catch {
|
||
// Tryb statyczny nie ma API; lokalne skróty nadal pozostają dostępne.
|
||
}
|
||
};
|
||
void poll();
|
||
const timer = window.setInterval(poll, 300);
|
||
return () => {
|
||
cancelled = true;
|
||
window.clearInterval(timer);
|
||
remoteScrollTokenRef.current += 1;
|
||
window.cancelAnimationFrame(remoteScrollFrameRef.current);
|
||
applyingRemoteScrollRef.current = false;
|
||
pendingRemoteNavigationReportRef.current = false;
|
||
};
|
||
}, []);
|
||
useEffect(() => {
|
||
void publishViewer({
|
||
nvim: {
|
||
connection: nvimSummary.state,
|
||
screen_cursor: nvimSummary.screenCursor,
|
||
grid: nvimSummary.grid,
|
||
},
|
||
});
|
||
}, [nvimSummary.grid?.columns, nvimSummary.grid?.rows, nvimSummary.screenCursor?.column, nvimSummary.screenCursor?.row, nvimSummary.state, publishViewer]);
|
||
useEffect(() => {
|
||
let timer = 0;
|
||
let pendingSheetContent: HTMLElement | null = null;
|
||
const sheets = Array.from(
|
||
document.querySelectorAll<HTMLElement>(".paper > .sheet"),
|
||
);
|
||
const sheetContents = sheets
|
||
.map(sheet => sheet.querySelector<HTMLElement>(".sheet-content"))
|
||
.filter((sheet): sheet is HTMLElement => Boolean(sheet));
|
||
const report = (event?: Event) => {
|
||
if (applyingRemoteScrollRef.current) return;
|
||
const source = event?.currentTarget;
|
||
if (source instanceof HTMLElement && source.classList.contains("sheet-content")) {
|
||
pendingSheetContent = source;
|
||
}
|
||
window.clearTimeout(timer);
|
||
timer = window.setTimeout(() => {
|
||
const sourceSheet = pendingSheetContent?.closest<HTMLElement>(".sheet");
|
||
const sourceIndex = sourceSheet ? sheets.indexOf(sourceSheet) : -1;
|
||
const pageIndex = sourceIndex >= 0
|
||
? sourceIndex
|
||
: sheets.reduce((best, sheet, index) => {
|
||
const bounds = sheet.getBoundingClientRect();
|
||
const distance = Math.abs(
|
||
(bounds.top + bounds.bottom) / 2 - window.innerHeight / 2,
|
||
);
|
||
return distance < best.distance ? { index, distance } : best;
|
||
}, { index: 0, distance: Number.POSITIVE_INFINITY }).index;
|
||
const sheetContent = sourceIndex >= 0
|
||
? pendingSheetContent
|
||
: sheets[pageIndex]?.querySelector<HTMLElement>(".sheet-content") ?? null;
|
||
void publishViewer({
|
||
scroll: {
|
||
x: window.scrollX,
|
||
y: window.scrollY,
|
||
page_index: pageIndex,
|
||
sheet_scroll_top: sheetContent?.scrollTop ?? 0,
|
||
},
|
||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||
});
|
||
pendingSheetContent = null;
|
||
}, 120);
|
||
};
|
||
window.addEventListener("scroll", report, { passive: true });
|
||
window.addEventListener("resize", report);
|
||
window.addEventListener("stem:viewer-scroll-settled", report);
|
||
sheetContents.forEach(sheet => sheet.addEventListener("scroll", report, { passive: true }));
|
||
report();
|
||
return () => {
|
||
window.clearTimeout(timer);
|
||
window.removeEventListener("scroll", report);
|
||
window.removeEventListener("resize", report);
|
||
window.removeEventListener("stem:viewer-scroll-settled", report);
|
||
sheetContents.forEach(sheet => sheet.removeEventListener("scroll", report));
|
||
};
|
||
}, [publishViewer]);
|
||
useEffect(() => {
|
||
const navigate = (event: KeyboardEvent) => {
|
||
if (event.repeat) return;
|
||
const browserCommand = event.altKey && !event.ctrlKey && !event.metaKey
|
||
? event.code
|
||
: "";
|
||
if (browserCommand === "Digit1") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
setNvimVisible(!nvimVisible);
|
||
return;
|
||
}
|
||
if (browserCommand === "Digit2") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
setNvimSyncMode(!nvimSyncMode);
|
||
return;
|
||
}
|
||
if (browserCommand === "Digit3") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
if (!nvimSyncMode) return;
|
||
setNvimVisible(true);
|
||
setNvimControlMode(!nvimControlMode);
|
||
return;
|
||
}
|
||
if (browserCommand === "Digit4") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
setNvimExpanded(!nvimExpanded);
|
||
return;
|
||
}
|
||
if (browserCommand === "Digit5") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
setNvimFit(!nvimFit);
|
||
return;
|
||
}
|
||
if (browserCommand === "Digit6") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
setAllocatorVisible(!allocatorVisible);
|
||
return;
|
||
}
|
||
if ((event.key === "F1" || event.key === "F2")
|
||
&& !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
void runWebFunctionKey(event.key);
|
||
return;
|
||
}
|
||
if (event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey
|
||
&& (event.code === "Backquote" || event.key === "`")) {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
void runWebRedraw();
|
||
return;
|
||
}
|
||
if (event.key === "F12") {
|
||
event.preventDefault();
|
||
event.stopImmediatePropagation();
|
||
setShortcutsOpen(current => !current);
|
||
return;
|
||
}
|
||
if (event.key === "Escape" && shortcutsOpen) {
|
||
event.preventDefault();
|
||
setShortcutsOpen(false);
|
||
return;
|
||
}
|
||
};
|
||
window.addEventListener("keydown", navigate, true);
|
||
return () => window.removeEventListener("keydown", navigate, true);
|
||
}, [allocatorVisible, nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, runWebFunctionKey, runWebRedraw, shortcutsOpen]);
|
||
useEffect(() => () => window.clearTimeout(shortcutStatusTimerRef.current), []);
|
||
useEffect(() => {
|
||
const wheel = (event: WheelEvent) => {
|
||
if (!event.altKey || event.ctrlKey) return;
|
||
event.preventDefault();
|
||
setScaleState((current) => {
|
||
const next = Math.max(
|
||
0.25,
|
||
Math.min(
|
||
3,
|
||
current *
|
||
Math.exp(-Math.max(-120, Math.min(120, event.deltaY)) * 0.001),
|
||
),
|
||
);
|
||
void publishViewer({ scale: next });
|
||
return next;
|
||
});
|
||
};
|
||
window.addEventListener("wheel", wheel, { passive: false });
|
||
return () => window.removeEventListener("wheel", wheel);
|
||
}, [publishViewer]);
|
||
const setStatus: SetProgressStatus = async (id, status) => {
|
||
try {
|
||
const response = await fetch(`/api/progress/${encodeURIComponent(id)}`, {
|
||
method: "PUT",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
if (!response.ok) throw new Error(await response.text());
|
||
setProgress((await response.json()).progress);
|
||
setProgressError("");
|
||
} catch (error) {
|
||
setProgressError(
|
||
`Nie zapisano postępu: ${error instanceof Error ? error.message : String(error)}`,
|
||
);
|
||
}
|
||
};
|
||
const cycle = (id: string, current: Status) => {
|
||
const next: Record<Status, Status> = {
|
||
pending: "approved",
|
||
approved: "pending",
|
||
};
|
||
void setStatus(id, next[current]);
|
||
};
|
||
let figureCount = 0;
|
||
return (
|
||
<>
|
||
<div
|
||
ref={viewerChromeRef}
|
||
className={`viewer-chrome${nvimExpanded ? " is-nvim-expanded" : ""}${nvimFit ? " is-nvim-fit" : ""}`}
|
||
>
|
||
<Topbar
|
||
toc={model.toc}
|
||
scale={scale}
|
||
setScale={setScale}
|
||
nvimVisible={nvimVisible}
|
||
setNvimVisible={setNvimVisible}
|
||
nvimSyncMode={nvimSyncMode}
|
||
setNvimSyncMode={setNvimSyncMode}
|
||
nvimControlMode={nvimControlMode}
|
||
setNvimControlMode={setNvimControlMode}
|
||
nvimExpanded={nvimExpanded}
|
||
setNvimExpanded={setNvimExpanded}
|
||
nvimFit={nvimFit}
|
||
setNvimFit={setNvimFit}
|
||
allocatorVisible={allocatorVisible}
|
||
setAllocatorVisible={setAllocatorVisible}
|
||
nvimSummary={nvimSummary}
|
||
/>
|
||
<AllocatorPanel visible={allocatorVisible} model={model} />
|
||
<NeovimPanel
|
||
visible={nvimVisible}
|
||
syncMode={nvimSyncMode}
|
||
controlMode={nvimControlMode}
|
||
expanded={nvimExpanded}
|
||
fit={nvimFit}
|
||
scale={scale}
|
||
onSummaryChange={setNvimSummary}
|
||
/>
|
||
{shortcutStatus && <output className="viewer-action-toast">{shortcutStatus}</output>}
|
||
</div>
|
||
<main className="paper">
|
||
<FrontPage front={model.front} />
|
||
{model.sections.map((section) => {
|
||
const offset = figureCount;
|
||
figureCount += section.assets.length;
|
||
return (
|
||
<SectionPage
|
||
key={section.id}
|
||
section={section}
|
||
progress={progress}
|
||
onCycle={cycle}
|
||
onSetStatus={setStatus}
|
||
figureOffset={offset}
|
||
/>
|
||
);
|
||
})}
|
||
{model.dictionary && (
|
||
<Sheet
|
||
header={model.dictionary.header_html}
|
||
className="dictionary-sheet"
|
||
>
|
||
<h2>Słownik WE/EN/EK/KW</h2>
|
||
<Html html={model.dictionary.content_html} />
|
||
</Sheet>
|
||
)}
|
||
</main>
|
||
<ProgressDock progress={progress} error={progressError} />
|
||
<ShortcutHelp open={shortcutsOpen} onClose={() => setShortcutsOpen(false)} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
async function start() {
|
||
const root = createRoot(document.getElementById("root")!);
|
||
try {
|
||
const response = await fetch("./card-data.json", { cache: "no-store" });
|
||
if (!response.ok)
|
||
throw new Error(`Nie można wczytać card-data.json: ${response.status}`);
|
||
const model = (await response.json()) as CardModel;
|
||
root.render(<App model={model} />);
|
||
} catch (error) {
|
||
root.render(
|
||
<main className="app-load-error">
|
||
<h1>Nie udało się uruchomić karty</h1>
|
||
<pre>{error instanceof Error ? error.message : String(error)}</pre>
|
||
</main>,
|
||
);
|
||
}
|
||
}
|
||
|
||
void start();
|