Files
bookhoard/web/src/reader/features/navigator-panel.ts
T
john-okeefe 2b22d7b255 reader: convert feature modules to Feature Registration Pattern
- Add init(context) exported function to each feature module
- Store module-level context reference for event handlers
- Fix gestures.ts to use library_type_name instead of library_type
- Features: gestures, keyboard-shortcuts, navigator-panel, offline-manager,
  panel-dock-system, progress-indicator, reading-speed-tracker
2026-04-04 18:17:16 -04:00

143 lines
4.0 KiB
TypeScript

// Navigator panel - shows full page with draggable viewport box
// Affinity/Photoshop-style mini-map for page navigation
// Feature Registration Pattern implementation
import type { ReaderContext } from "../core/reader-context";
let context: ReaderContext;
export function init(readerContext: ReaderContext): void {
context = readerContext;
let state: NavigatorState | null = null;
context.events.on(
"navigator:initialize",
(detail: { containerSelector: string; totalPages: number }) => {
state = initializeNavigator(detail.containerSelector);
state.totalPages = detail.totalPages;
},
);
context.events.on(
"navigator:update",
(detail: { currentPage: number; contentImage?: HTMLImageElement }) => {
if (state) {
state.currentPage = detail.currentPage;
if (detail.contentImage) {
state.contentImage = detail.contentImage;
updateNavigatorViewport(state);
}
}
},
);
context.events.on("navigator:pan", (detail: { x: number; y: number }) => {
if (state) {
handleNavigatorPan(state, detail.x, detail.y);
}
});
}
interface NavigatorState {
panelId: string;
container: HTMLElement;
viewport: HTMLElement;
currentPage: number;
totalPages: number;
scale: number;
contentImage: HTMLImageElement | null;
isDragging: boolean;
}
function initializeNavigator(containerSelector: string): NavigatorState {
const container = document.querySelector(containerSelector) as HTMLElement;
if (!container) throw new Error("Navigator container not found");
const viewport = document.createElement("div");
viewport.className = "navigator-viewport-box";
viewport.style.cssText = `
position: absolute;
border: 2px solid var(--accent-color, #3b82f6);
background: rgba(59, 130, 246, 0.1);
cursor: move;
z-index: 10;
`;
container.appendChild(viewport);
const state: NavigatorState = {
panelId: "navigator",
container,
viewport,
currentPage: 1,
totalPages: 1,
scale: 0.1,
contentImage: null,
isDragging: false,
};
setupNavigatorDragHandler(state);
return state;
}
function setupNavigatorDragHandler(state: NavigatorState): void {
state.viewport.addEventListener("mousedown", (e) => {
e.preventDefault();
state.isDragging = true;
state.viewport.style.cursor = "grabbing";
});
document.addEventListener("mousemove", (e) => {
if (!state.isDragging || !state.contentImage) return;
const containerRect = state.container.getBoundingClientRect();
const imgRect = state.contentImage.getBoundingClientRect();
const relX = (e.clientX - imgRect.left) / imgRect.width;
const relY = (e.clientY - imgRect.top) / imgRect.height;
const mainViewer = document.getElementById("reader-content");
if (mainViewer) {
mainViewer.dispatchEvent(
new CustomEvent("navigator-pan", {
detail: { x: relX, y: relY },
}),
);
}
});
document.addEventListener("mouseup", () => {
if (state.isDragging) {
state.isDragging = false;
state.viewport.style.cursor = "move";
}
});
}
function updateNavigatorViewport(state: NavigatorState): void {
if (!state.contentImage) return;
const containerRect = state.container.getBoundingClientRect();
const imgRect = state.contentImage.getBoundingClientRect();
state.scale = containerRect.width / imgRect.width;
state.viewport.style.width = `${containerRect.width}px`;
state.viewport.style.height = `${containerRect.height * state.scale}px`;
state.viewport.style.left = "0";
state.viewport.style.top = `${(state.currentPage - 1) * imgRect.height * state.scale}px`;
}
function handleNavigatorPan(state: NavigatorState, x: number, y: number): void {
if (!state.contentImage) return;
const imgRect = state.contentImage.getBoundingClientRect();
const viewportX = x * state.container.offsetWidth;
const viewportY = y * state.container.offsetHeight;
state.viewport.style.left = `${viewportX}px`;
state.viewport.style.top = `${viewportY}px`;
}