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 }; } }