377 lines
17 KiB
JavaScript
377 lines
17 KiB
JavaScript
const NAVIGATION_LEVELS = ['card', 'task', 'block', 'phase', 'step', 'snapshot'];
|
|
|
|
export const KEYBOARD_SHORTCUTS = [
|
|
{ keys: 'Alt+ArrowUp/Alt+ArrowDown', action: 'navigation.move', level: 'task', description: 'poprzedni/następny TASK' },
|
|
{ keys: 'Ctrl+ArrowUp/Ctrl+ArrowDown', action: 'navigation.move', level: 'block', description: 'poprzedni/następny BLOCK' },
|
|
{ keys: 'Shift+ArrowUp/Shift+ArrowDown', action: 'navigation.move', level: 'phase', description: 'poprzednia/następna PHASE' },
|
|
{ keys: 'Ctrl+Shift+ArrowUp/Ctrl+Shift+ArrowDown', action: 'navigation.move', level: 'step', description: 'poprzedni/następny STEP' },
|
|
{ keys: 'Alt+Shift+ArrowUp/Alt+Shift+ArrowDown', action: 'navigation.move', level: 'snapshot', description: 'poprzedni/następny unikalny SNAPSHOT' },
|
|
{ keys: 'ArrowUp/ArrowDown', action: 'navigation.move', level: 'current', description: 'poprzedni/następny element aktywnego poziomu' },
|
|
{ keys: 'ArrowRight', action: 'navigation.level', direction: 'child', description: 'zejdź poziom niżej' },
|
|
{ keys: 'ArrowLeft', action: 'navigation.level', direction: 'parent', description: 'wróć poziom wyżej' },
|
|
{ keys: 'Enter', action: 'navigation.activate', description: 'wybierz element i przy SYNC ON odtwórz jego stan' },
|
|
{ keys: 'F1', action: 'nvim.reset', description: 'zresetuj aktualny cel i odśwież debugger' },
|
|
{ keys: 'F2', action: 'navigation.activate', description: 'zsynchronizuj cel z kursorem UML; BLOCK wybiera pierwszy krok' },
|
|
{ keys: 'Ctrl+Backquote', action: 'nvim.redraw', description: 'maksymalizuj pane, dopasuj siatkę i odśwież Neovima' },
|
|
{ keys: 'Ctrl+Enter', action: 'progress.toggle', description: 'zatwierdź lub cofnij zatwierdzenie bieżącego kroku' },
|
|
{ keys: 'Escape', action: 'navigation.level', direction: 'parent', description: 'anuluj albo wróć poziom wyżej' },
|
|
{ keys: 'Alt+Digit1', action: 'viewer.nvim.visible.toggle', description: 'pokaż/ukryj panel Neovima' },
|
|
{ keys: 'Alt+Digit2', action: 'viewer.nvim.sync.toggle', description: 'przełącz SYNC OFF/SYNC ON' },
|
|
{ keys: 'Alt+Digit3', action: 'viewer.nvim.control.toggle', description: 'uzbrój/rozbrój sterowanie Neovimem' },
|
|
{ keys: 'Alt+Digit4', action: 'viewer.nvim.expanded.toggle', description: 'przełącz normalną/wysoką wysokość panelu' },
|
|
{ keys: 'Alt+Digit5', action: 'viewer.nvim.fit.toggle', description: 'dopasuj cały ekran Neovima' },
|
|
{ keys: 'Alt+Digit6', action: 'viewer.allocator.visible.toggle', description: 'przypnij/ukryj model alokatora' },
|
|
{ keys: 'F12', action: 'viewer.keyboard.toggle', description: 'pokaż/ukryj skorowidz klawiatury' }
|
|
];
|
|
|
|
function text(value, fallback = '') {
|
|
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
|
}
|
|
|
|
function node(value, fallbackId, fallbackLabel) {
|
|
return {
|
|
id: text(value?.id, fallbackId),
|
|
label: text(value?.label, fallbackLabel)
|
|
};
|
|
}
|
|
|
|
export function navigationCatalog(card) {
|
|
const entries = [];
|
|
const taskIds = [];
|
|
const blockIds = new Map();
|
|
const globalSnapshots = [];
|
|
|
|
for (const [sectionIndex, section] of (card.sections ?? []).entries()) {
|
|
for (const [assetIndex, asset] of (section.assets ?? []).entries()) {
|
|
const interactive = asset.interactive;
|
|
if (!interactive?.phases?.length) continue;
|
|
const task = node(
|
|
interactive.task,
|
|
`section-${sectionIndex + 1}`,
|
|
text(section.title, `Sekcja ${sectionIndex + 1}`)
|
|
);
|
|
const block = node(
|
|
interactive.block,
|
|
text(asset.label, `asset-${assetIndex + 1}`).replace(/^fig:/, ''),
|
|
text(interactive.title, text(asset.caption, `Blok ${assetIndex + 1}`))
|
|
);
|
|
if (!taskIds.includes(task.id)) taskIds.push(task.id);
|
|
const taskBlocks = blockIds.get(task.id) ?? [];
|
|
if (!taskBlocks.includes(block.id)) taskBlocks.push(block.id);
|
|
blockIds.set(task.id, taskBlocks);
|
|
const blockPhases = interactive.phases;
|
|
const blockSnapshots = [];
|
|
|
|
for (const [phaseIndex, phaseValue] of blockPhases.entries()) {
|
|
const phase = node(phaseValue, `phase-${phaseIndex + 1}`, `Faza ${phaseIndex + 1}`);
|
|
const phaseSnapshots = [];
|
|
for (const [stepIndex, stepValue] of (phaseValue.steps ?? []).entries()) {
|
|
const step = node(stepValue, `step-${stepIndex + 1}`, `Krok ${stepIndex + 1}`);
|
|
const snapshotRef = text(stepValue.snapshot_ref) || null;
|
|
if (snapshotRef && !phaseSnapshots.includes(snapshotRef)) phaseSnapshots.push(snapshotRef);
|
|
if (snapshotRef && !blockSnapshots.includes(snapshotRef)) blockSnapshots.push(snapshotRef);
|
|
if (snapshotRef && !globalSnapshots.includes(snapshotRef)) globalSnapshots.push(snapshotRef);
|
|
entries.push({
|
|
task: { ...task, index: taskIds.indexOf(task.id) },
|
|
block: { ...block, index: taskBlocks.indexOf(block.id) },
|
|
phase: { ...phase, index: phaseIndex },
|
|
step: {
|
|
...step,
|
|
number: Number.isSafeInteger(Number(stepValue.number)) ? Number(stepValue.number) : stepIndex + 1,
|
|
index: stepIndex,
|
|
global_index: entries.length
|
|
},
|
|
snapshot: snapshotRef ? {
|
|
ref: snapshotRef,
|
|
index: phaseSnapshots.indexOf(snapshotRef),
|
|
block_index: blockSnapshots.indexOf(snapshotRef),
|
|
global_index: globalSnapshots.indexOf(snapshotRef)
|
|
} : null,
|
|
asset: {
|
|
label: text(asset.label),
|
|
section_index: sectionIndex,
|
|
asset_index: assetIndex
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
schema: 'stem-card-navigation-catalog.v1',
|
|
levels: NAVIGATION_LEVELS,
|
|
entries
|
|
};
|
|
}
|
|
|
|
function integer(value) {
|
|
const parsed = Number(value);
|
|
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
}
|
|
|
|
function entryMatches(entry, request) {
|
|
const ids = {
|
|
task: text(request.task?.id, text(request.task_id)),
|
|
block: text(request.block?.id, text(request.block_id)),
|
|
phase: text(request.phase?.id, text(request.phase_id)),
|
|
step: text(request.step?.id, text(request.step_id))
|
|
};
|
|
for (const [level, id] of Object.entries(ids)) {
|
|
if (id && entry[level].id !== id) return false;
|
|
}
|
|
const snapshotRef = text(request.snapshot_ref, text(request.snapshot?.ref));
|
|
return !snapshotRef || entry.snapshot?.ref === snapshotRef;
|
|
}
|
|
|
|
function findNavigationEntry(catalog, request) {
|
|
const globalStep = integer(request.position?.global_step_index);
|
|
if (globalStep != null) return catalog.entries[globalStep] ?? null;
|
|
const matches = catalog.entries.filter(entry => entryMatches(entry, request));
|
|
if (!matches.length) return null;
|
|
const phaseIndex = integer(request.position?.phase_index);
|
|
const stepIndex = integer(request.position?.step_index);
|
|
const snapshotIndex = integer(request.position?.snapshot_index);
|
|
return matches.find(entry =>
|
|
(phaseIndex == null || entry.phase.index === phaseIndex)
|
|
&& (stepIndex == null || entry.step.index === stepIndex)
|
|
&& (snapshotIndex == null || entry.snapshot?.index === snapshotIndex)
|
|
) ?? matches[0];
|
|
}
|
|
|
|
export function selectNavigation(catalog, request, revision = 1, now = new Date().toISOString()) {
|
|
if (!request || typeof request !== 'object') throw new Error('Stan nawigacji musi być obiektem.');
|
|
const entry = findNavigationEntry(catalog, request);
|
|
if (!entry) throw new Error('Nie znaleziono wskazanej pozycji w katalogu karty.');
|
|
const focusLevel = text(request.focus_level, 'step');
|
|
if (!NAVIGATION_LEVELS.includes(focusLevel)) {
|
|
throw new Error(`focus_level musi być jednym z: ${NAVIGATION_LEVELS.join(', ')}.`);
|
|
}
|
|
return {
|
|
schema: 'stem-card-navigation.v2',
|
|
revision,
|
|
selected_at: now,
|
|
actor: text(request.actor, 'browser'),
|
|
focus_level: focusLevel,
|
|
cursor_visible: focusLevel !== 'card',
|
|
task: entry.task,
|
|
block: entry.block,
|
|
phase: entry.phase,
|
|
step: entry.step,
|
|
snapshot: entry.snapshot,
|
|
snapshot_ref: entry.snapshot?.ref ?? null,
|
|
position: {
|
|
task_index: entry.task.index,
|
|
block_index: entry.block.index,
|
|
phase_index: entry.phase.index,
|
|
step_index: entry.step.index,
|
|
global_step_index: entry.step.global_index,
|
|
snapshot_index: entry.snapshot?.index ?? null,
|
|
global_snapshot_index: entry.snapshot?.global_index ?? null
|
|
},
|
|
sync_requested: request.sync_requested === true
|
|
};
|
|
}
|
|
|
|
function uniqueAtLevel(entries, level, current) {
|
|
const seen = new Set();
|
|
return entries.filter(entry => {
|
|
let key;
|
|
if (level === 'task') key = entry.task.id;
|
|
else if (level === 'block') key = `${entry.task.id}/${entry.block.id}`;
|
|
else if (level === 'phase') key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}`;
|
|
else if (level === 'snapshot') key = entry.snapshot?.ref;
|
|
else key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}/${entry.step.id}`;
|
|
if (!key || seen.has(key)) return false;
|
|
seen.add(key);
|
|
if (level === 'phase') return entry.task.id === current.task.id && entry.block.id === current.block.id;
|
|
if (level === 'step' || level === 'snapshot') {
|
|
return entry.task.id === current.task.id && entry.block.id === current.block.id;
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function moveNavigation(catalog, current, command, revision = 1, now = new Date().toISOString()) {
|
|
if (!current) throw new Error('Najpierw ustaw bieżącą pozycję nawigacji.');
|
|
const requestedLevel = text(command?.level, current.focus_level);
|
|
const level = requestedLevel === 'current' ? current.focus_level : requestedLevel;
|
|
if (!NAVIGATION_LEVELS.includes(level)) throw new Error('Niepoprawny poziom nawigacji.');
|
|
if (level === 'card') throw new Error('Poziom CARD nie ma elementów UML do przewijania.');
|
|
const delta = integer(command?.delta);
|
|
if (delta == null || delta === 0 || Math.abs(delta) > 100) throw new Error('delta musi być niezerową liczbą całkowitą.');
|
|
const candidates = uniqueAtLevel(catalog.entries, level, current);
|
|
const currentIndex = candidates.findIndex(entry => {
|
|
if (level === 'task') return entry.task.id === current.task.id;
|
|
if (level === 'block') return entry.block.id === current.block.id && entry.task.id === current.task.id;
|
|
if (level === 'phase') return entry.phase.id === current.phase.id;
|
|
if (level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
|
|
return entry.step.id === current.step.id && entry.phase.id === current.phase.id;
|
|
});
|
|
const targetIndex = Math.max(0, Math.min(candidates.length - 1, Math.max(0, currentIndex) + delta));
|
|
const target = candidates[targetIndex];
|
|
if (!target) throw new Error('Brak elementu na wskazanym poziomie.');
|
|
return selectNavigation(catalog, {
|
|
task_id: target.task.id,
|
|
block_id: target.block.id,
|
|
phase_id: target.phase.id,
|
|
step_id: target.step.id,
|
|
snapshot_ref: target.snapshot?.ref,
|
|
focus_level: level,
|
|
// Moving the teaching cursor never mutates the debugger. Synchronisation
|
|
// is a separate, explicit activation (Enter in WWW or F2 in Neovim).
|
|
sync_requested: false,
|
|
actor: text(command.actor, 'api')
|
|
}, revision, now);
|
|
}
|
|
|
|
export function activateNavigation(catalog, current, actor = 'nvim', revision = 1, now = new Date().toISOString()) {
|
|
if (!current) throw new Error('Najpierw wybierz BLOCK zawierający diagram UML.');
|
|
if (current.focus_level === 'card' || current.focus_level === 'task') {
|
|
throw new Error('F2 wymaga zaznaczonego BLOCK, PHASE, STEP albo SNAPSHOT.');
|
|
}
|
|
|
|
const target = catalog.entries.find(entry => {
|
|
if (entry.task.id !== current.task.id || entry.block.id !== current.block.id) return false;
|
|
if (current.focus_level === 'block') return true;
|
|
if (entry.phase.id !== current.phase.id) return false;
|
|
if (current.focus_level === 'phase') return true;
|
|
if (current.focus_level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
|
|
return entry.step.id === current.step.id;
|
|
});
|
|
if (!target) throw new Error('Nie znaleziono kroku UML, który można zsynchronizować.');
|
|
if (!target.snapshot?.ref) throw new Error('Wybrany krok UML nie ma snapshotu do synchronizacji.');
|
|
|
|
return selectNavigation(catalog, {
|
|
task_id: target.task.id,
|
|
block_id: target.block.id,
|
|
phase_id: target.phase.id,
|
|
step_id: target.step.id,
|
|
snapshot_ref: target.snapshot.ref,
|
|
focus_level: 'step',
|
|
sync_requested: true,
|
|
actor: text(actor, 'nvim')
|
|
}, revision, now);
|
|
}
|
|
|
|
export function initialViewerState() {
|
|
return {
|
|
schema: 'stem-card-viewer-state.v1',
|
|
revision: 0,
|
|
updated_at: null,
|
|
actor: null,
|
|
scale: 2.18,
|
|
scroll: { x: 0, y: 0, page_index: 0, sheet_scroll_top: 0 },
|
|
viewport: { width: 0, height: 0 },
|
|
allocator: { visible: true },
|
|
nvim: {
|
|
visible: true,
|
|
sync: false,
|
|
control: false,
|
|
expanded: false,
|
|
fit: false,
|
|
connection: 'unknown',
|
|
screen_cursor: null,
|
|
grid: null
|
|
}
|
|
};
|
|
}
|
|
|
|
function finite(value, minimum, maximum, name) {
|
|
const parsed = Number(value);
|
|
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
|
|
throw new Error(`${name} jest poza zakresem ${minimum}..${maximum}.`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function boolean(value, fallback) {
|
|
return typeof value === 'boolean' ? value : fallback;
|
|
}
|
|
|
|
export function patchViewerState(current, request, revision = current.revision + 1, now = new Date().toISOString()) {
|
|
if (!request || typeof request !== 'object') throw new Error('Stan viewer musi być obiektem.');
|
|
const patch = request.viewer && typeof request.viewer === 'object' ? request.viewer : request;
|
|
const scroll = patch.scroll && typeof patch.scroll === 'object' ? patch.scroll : {};
|
|
const viewport = patch.viewport && typeof patch.viewport === 'object' ? patch.viewport : {};
|
|
const allocator = patch.allocator && typeof patch.allocator === 'object' ? patch.allocator : {};
|
|
const nvim = patch.nvim && typeof patch.nvim === 'object' ? patch.nvim : {};
|
|
const cursor = nvim.screen_cursor && typeof nvim.screen_cursor === 'object'
|
|
? {
|
|
row: Math.trunc(finite(nvim.screen_cursor.row, 0, 100000, 'nvim.screen_cursor.row')),
|
|
column: Math.trunc(finite(nvim.screen_cursor.column, 0, 100000, 'nvim.screen_cursor.column'))
|
|
}
|
|
: current.nvim.screen_cursor;
|
|
const grid = nvim.grid && typeof nvim.grid === 'object'
|
|
? {
|
|
columns: Math.trunc(finite(nvim.grid.columns, 1, 10000, 'nvim.grid.columns')),
|
|
rows: Math.trunc(finite(nvim.grid.rows, 1, 10000, 'nvim.grid.rows'))
|
|
}
|
|
: current.nvim.grid;
|
|
const sync = boolean(nvim.sync, current.nvim.sync);
|
|
const control = sync && boolean(nvim.control, current.nvim.control);
|
|
const visible = boolean(nvim.visible, current.nvim.visible);
|
|
const fit = visible && boolean(nvim.fit, current.nvim.fit);
|
|
const expanded = visible && !fit && boolean(nvim.expanded, current.nvim.expanded);
|
|
const pageIndex = scroll.page_index == null
|
|
? current.scroll.page_index
|
|
: Math.trunc(finite(scroll.page_index, 0, 100000, 'scroll.page_index'));
|
|
const pageChanged = scroll.page_index != null && pageIndex !== current.scroll.page_index;
|
|
return {
|
|
schema: 'stem-card-viewer-state.v1',
|
|
revision,
|
|
updated_at: now,
|
|
actor: text(request.actor, 'browser'),
|
|
scale: patch.scale == null ? current.scale : finite(patch.scale, 0.25, 3, 'scale'),
|
|
scroll: {
|
|
x: scroll.x == null ? current.scroll.x : finite(scroll.x, 0, 10000000, 'scroll.x'),
|
|
y: scroll.y == null ? current.scroll.y : finite(scroll.y, 0, 10000000, 'scroll.y'),
|
|
page_index: pageIndex,
|
|
sheet_scroll_top: scroll.sheet_scroll_top == null
|
|
? (pageChanged ? 0 : current.scroll.sheet_scroll_top ?? 0)
|
|
: finite(scroll.sheet_scroll_top, 0, 10000000, 'scroll.sheet_scroll_top')
|
|
},
|
|
viewport: {
|
|
width: viewport.width == null ? current.viewport.width : finite(viewport.width, 0, 100000, 'viewport.width'),
|
|
height: viewport.height == null ? current.viewport.height : finite(viewport.height, 0, 100000, 'viewport.height')
|
|
},
|
|
allocator: {
|
|
visible: boolean(allocator.visible, current.allocator?.visible ?? true)
|
|
},
|
|
nvim: {
|
|
visible,
|
|
sync,
|
|
control,
|
|
expanded,
|
|
fit,
|
|
connection: text(nvim.connection, current.nvim.connection),
|
|
screen_cursor: cursor,
|
|
grid
|
|
}
|
|
};
|
|
}
|
|
|
|
export function normalizeViewerState(value) {
|
|
const initial = initialViewerState();
|
|
if (!value || typeof value !== 'object') return initial;
|
|
const revisionValue = Number(value.revision);
|
|
const revision = Number.isSafeInteger(revisionValue) && revisionValue >= 0
|
|
? revisionValue
|
|
: 0;
|
|
const updatedAt = typeof value.updated_at === 'string' ? value.updated_at : null;
|
|
const actor = typeof value.actor === 'string' && value.actor.trim()
|
|
? value.actor.trim()
|
|
: null;
|
|
try {
|
|
const normalized = patchViewerState(
|
|
initial,
|
|
{ actor: actor ?? 'browser', viewer: value },
|
|
revision,
|
|
updatedAt
|
|
);
|
|
return { ...normalized, actor };
|
|
} catch {
|
|
return { ...initial, revision, updated_at: updatedAt, actor };
|
|
}
|
|
}
|