feat: add pinned allocator lesson view
This commit is contained in:
+300
-18
@@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client";
|
||||
import "./react.css";
|
||||
|
||||
type Status = "pending" | "approved";
|
||||
type NavigationLevel = "task" | "block" | "phase" | "step" | "snapshot";
|
||||
type NavigationLevel = "card" | "task" | "block" | "phase" | "step" | "snapshot";
|
||||
type NavigationPosition = {
|
||||
task_index: number;
|
||||
block_index: number;
|
||||
@@ -36,6 +36,7 @@ type ApiViewerState = {
|
||||
sheet_scroll_top?: number;
|
||||
};
|
||||
viewport: { width: number; height: number };
|
||||
allocator?: { visible: boolean };
|
||||
nvim: {
|
||||
visible: boolean;
|
||||
sync: boolean;
|
||||
@@ -465,6 +466,8 @@ function Topbar({
|
||||
setNvimExpanded,
|
||||
nvimFit,
|
||||
setNvimFit,
|
||||
allocatorVisible,
|
||||
setAllocatorVisible,
|
||||
nvimSummary,
|
||||
}: {
|
||||
toc: CardModel["toc"];
|
||||
@@ -480,6 +483,8 @@ function Topbar({
|
||||
setNvimExpanded: (value: boolean) => void;
|
||||
nvimFit: boolean;
|
||||
setNvimFit: (value: boolean) => void;
|
||||
allocatorVisible: boolean;
|
||||
setAllocatorVisible: (value: boolean) => void;
|
||||
nvimSummary: NvimPanelSummary;
|
||||
}) {
|
||||
return (
|
||||
@@ -549,6 +554,16 @@ function Topbar({
|
||||
>
|
||||
<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 && (
|
||||
@@ -561,7 +576,7 @@ function Topbar({
|
||||
{nvimSummary.detail && <small>{nvimSummary.detail}</small>}
|
||||
</span>
|
||||
<span className="nvim-shortcut-strip" aria-label="Skróty panelu Neovima">
|
||||
<kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
|
||||
<kbd>F1</kbd> reset · <kbd>F2</kbd> sync · <kbd>Alt+W</kbd> okna · <kbd>F12</kbd> skróty
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -608,6 +623,138 @@ type ActiveNvimStep = {
|
||||
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;
|
||||
@@ -1253,6 +1400,25 @@ function NeovimPanel({
|
||||
});
|
||||
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;
|
||||
@@ -2168,7 +2334,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
);
|
||||
const [focusLevel, setFocusLevel] = useState<NavigationLevel>(() => {
|
||||
const saved = localStorage.getItem(focusStorageKey);
|
||||
return saved === "task" || saved === "block" || saved === "phase"
|
||||
return saved === "card" || saved === "task" || saved === "block" || saved === "phase"
|
||||
|| saved === "step" || saved === "snapshot"
|
||||
? saved
|
||||
: "step";
|
||||
@@ -2304,7 +2470,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
);
|
||||
hit.dataset.umlStep = String(index);
|
||||
hit.classList.add("uml-step-hit");
|
||||
hit.classList.toggle("uml-step-hit-active", index === stepIndex);
|
||||
hit.classList.toggle("uml-step-hit-active", focusLevel !== "card" && index === stepIndex);
|
||||
hit.classList.toggle("uml-step-hit-completed", completed);
|
||||
svg.appendChild(hit);
|
||||
});
|
||||
@@ -2390,9 +2556,14 @@ function SnapshotInteractiveUmlSequence({
|
||||
focusLevel: level,
|
||||
activate: activate && document.documentElement.dataset.nvimSyncMode === "on",
|
||||
};
|
||||
window.dispatchEvent(new CustomEvent<ActiveNvimStep>("stem:nvim-step", { detail }));
|
||||
if (!persist) return;
|
||||
void fetch("/api/navigation/current", {
|
||||
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({
|
||||
@@ -2409,11 +2580,19 @@ function SnapshotInteractiveUmlSequence({
|
||||
sync_requested: Boolean(detail.activate),
|
||||
actor: document.documentElement.dataset.cardClientId ?? "browser",
|
||||
}),
|
||||
}).catch(() => undefined);
|
||||
});
|
||||
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 = true,
|
||||
activate = false,
|
||||
level: NavigationLevel = focusLevel,
|
||||
persist = true,
|
||||
) => {
|
||||
@@ -2455,7 +2634,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
);
|
||||
});
|
||||
const next = Number(hits[0]?.dataset.umlStep);
|
||||
if (Number.isInteger(next)) selectIndex(next, true, "step");
|
||||
if (Number.isInteger(next)) selectIndex(next, false, "step");
|
||||
};
|
||||
const handleSvgKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
@@ -2463,11 +2642,11 @@ function SnapshotInteractiveUmlSequence({
|
||||
const next = Number(target.dataset?.umlStep);
|
||||
if (!Number.isInteger(next)) return;
|
||||
event.preventDefault();
|
||||
selectIndex(next, true, "step");
|
||||
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, true, "phase");
|
||||
if (next >= 0) selectIndex(next, false, "phase");
|
||||
};
|
||||
|
||||
const active = entries[stepIndex];
|
||||
@@ -2494,7 +2673,9 @@ function SnapshotInteractiveUmlSequence({
|
||||
entry.phase.id === command.phase.id && entry.step.id === command.step.id,
|
||||
);
|
||||
if (next < 0) return;
|
||||
selectIndex(next, command.sync_requested, command.focus_level, false);
|
||||
// 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);
|
||||
@@ -2536,7 +2717,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
return;
|
||||
}
|
||||
|
||||
const hierarchy: NavigationLevel[] = ["task", "block", "phase", "step", "snapshot"];
|
||||
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;
|
||||
@@ -2552,6 +2733,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
}
|
||||
|
||||
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";
|
||||
@@ -2568,7 +2750,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
body: JSON.stringify({
|
||||
level,
|
||||
delta: direction,
|
||||
sync_requested: document.documentElement.dataset.nvimSyncMode === "on",
|
||||
sync_requested: false,
|
||||
actor: document.documentElement.dataset.cardClientId ?? "browser",
|
||||
}),
|
||||
})
|
||||
@@ -2628,7 +2810,7 @@ function SnapshotInteractiveUmlSequence({
|
||||
<button
|
||||
key={entry.step.id}
|
||||
aria-label={`Krok ${entry.step.number}: ${entry.step.label}`}
|
||||
aria-pressed={index === stepIndex}
|
||||
aria-pressed={focusLevel !== "card" && index === stepIndex}
|
||||
data-completed={isDone ? "true" : "false"}
|
||||
onClick={() => selectIndex(index)}
|
||||
>
|
||||
@@ -2857,6 +3039,10 @@ function ProgressDock({
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -2873,12 +3059,16 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
["→", "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 + `", "przelicz siatkę i odśwież cały widok 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"],
|
||||
@@ -2896,7 +3086,7 @@ function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void })
|
||||
<header>
|
||||
<div>
|
||||
<small>F12 · NAWIGACJA DYDAKTYCZNA</small>
|
||||
<h2 id="shortcut-help-title">TASK → BLOCK → PHASE → STEP → SNAPSHOT</h2>
|
||||
<h2 id="shortcut-help-title">CARD → TASK → BLOCK → PHASE → STEP → SNAPSHOT</h2>
|
||||
</div>
|
||||
<button onClick={onClose} aria-label="Zamknij skorowidz">×</button>
|
||||
</header>
|
||||
@@ -2930,6 +3120,7 @@ function App({ model }: { model: CardModel }) {
|
||||
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",
|
||||
@@ -2943,6 +3134,9 @@ function App({ model }: { model: CardModel }) {
|
||||
);
|
||||
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,
|
||||
@@ -2953,6 +3147,60 @@ function App({ model }: { model: CardModel }) {
|
||||
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", {
|
||||
@@ -3030,6 +3278,11 @@ function App({ model }: { model: CardModel }) {
|
||||
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(() => {
|
||||
@@ -3069,6 +3322,7 @@ function App({ model }: { model: CardModel }) {
|
||||
expanded: nvimExpanded,
|
||||
fit: nvimFit,
|
||||
},
|
||||
allocator: { visible: allocatorVisible },
|
||||
};
|
||||
const applyViewer = (viewer: ApiViewerState) => {
|
||||
const fit = viewer.nvim.visible && viewer.nvim.fit;
|
||||
@@ -3079,12 +3333,15 @@ function App({ model }: { model: CardModel }) {
|
||||
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);
|
||||
@@ -3267,6 +3524,26 @@ function App({ model }: { model: CardModel }) {
|
||||
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();
|
||||
@@ -3281,7 +3558,8 @@ function App({ model }: { model: CardModel }) {
|
||||
};
|
||||
window.addEventListener("keydown", navigate, true);
|
||||
return () => window.removeEventListener("keydown", navigate, true);
|
||||
}, [nvimControlMode, nvimExpanded, nvimFit, nvimSyncMode, nvimVisible, shortcutsOpen]);
|
||||
}, [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;
|
||||
@@ -3346,8 +3624,11 @@ function App({ model }: { model: CardModel }) {
|
||||
setNvimExpanded={setNvimExpanded}
|
||||
nvimFit={nvimFit}
|
||||
setNvimFit={setNvimFit}
|
||||
allocatorVisible={allocatorVisible}
|
||||
setAllocatorVisible={setAllocatorVisible}
|
||||
nvimSummary={nvimSummary}
|
||||
/>
|
||||
<AllocatorPanel visible={allocatorVisible} model={model} />
|
||||
<NeovimPanel
|
||||
visible={nvimVisible}
|
||||
syncMode={nvimSyncMode}
|
||||
@@ -3357,6 +3638,7 @@ function App({ model }: { model: CardModel }) {
|
||||
scale={scale}
|
||||
onSummaryChange={setNvimSummary}
|
||||
/>
|
||||
{shortcutStatus && <output className="viewer-action-toast">{shortcutStatus}</output>}
|
||||
</div>
|
||||
<main className="paper">
|
||||
<FrontPage front={model.front} />
|
||||
|
||||
Reference in New Issue
Block a user