Files
bookhoard/web/src/reader/panel-dock-system.ts
T
john-okeefe d3d84a8318 Implement core reader TypeScript modules for shell and UI management
- reader-shell.ts: Main initialization, Alpine.js integration, media type detection
- progress-indicator.ts: Reading progress tracking and display components
- settings-manager.ts: User settings persistence and retrieval
- panel-dock-system.ts: Dockable panel management with drag/drop and collapse
- parser-manager.ts: Parser selection and format detection system

These core modules provide the foundation for all reader types with
shared functionality for progress tracking, settings management, and
the flexible panel docking system.
2026-04-03 22:29:20 -04:00

248 lines
6.7 KiB
TypeScript

// Modular dockable panel system - handles drag, lock, snap-back, window-shade
// Procedural style: Functions, not classes
import {
saveSettings,
loadSettings,
getDefaultSettings,
} from "./settings-manager";
interface PanelDockState {
panels: Map<string, PanelState>;
dragState: DragState | null;
dockZones: DockZone[];
}
interface DragState {
panelId: string;
startX: number;
startY: number;
currentX: number;
currentY: number;
isLocked: boolean;
}
interface DockZone {
side: "left" | "right";
x: number;
width: number;
height: number;
}
const state: PanelDockState = {
panels: new Map(),
dragState: null,
dockZones: [
{ side: "left", x: 0, width: 400, height: window.innerHeight },
{
side: "right",
x: window.innerWidth - 400,
width: 400,
height: window.innerHeight,
},
],
};
// Initialize all panels from settings
function initializePanelDockSystem(): void {
const settings = loadSettings();
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
registerPanel(panelId, panelState);
}
setupDragHandlers();
setupWindowShadeHandlers();
setupLockHandlers();
}
// Register a panel with the dock system
function registerPanel(panelId: string, panelState: PanelState): void {
state.panels.set(panelId, panelState);
applyPanelState(panelId, panelState);
}
// Apply panel state to DOM
function applyPanelState(panelId: string, panelState: PanelState): void {
const panel = document.querySelector(`[data-panel="${panelId}"]`);
if (!panel) return;
const container = panel.parentElement;
if (!container) return;
// Apply side positioning
if (panelState.side === "left") {
container.style.left = "0";
container.style.right = "auto";
} else if (panelState.side === "right") {
container.style.right = "0";
container.style.left = "auto";
} else {
container.style.left = "-9999px";
}
// Apply width
panel.style.width = `${panelState.width_px}px`;
// Apply collapsed (window-shade) state
if (panelState.collapsed) {
panel.classList.add("panel-collapsed");
panel.querySelector(".panel-content")?.classList.add("hidden");
} else {
panel.classList.remove("panel-collapsed");
panel.querySelector(".panel-content")?.classList.remove("hidden");
}
// Apply lock state
const lockBtn = panel.querySelector(".panel-lock");
if (lockBtn) {
lockBtn.textContent = panelState.locked ? "🔒" : "🔓";
}
}
// Setup mouse/touch drag handlers
function setupDragHandlers(): void {
document
.querySelectorAll(".dockable-panel .panel-header")
.forEach((header) => {
header.addEventListener("mousedown", handleDragStart);
header.addEventListener("touchstart", handleDragStart, {
passive: false,
});
});
document.addEventListener("mousemove", handleDragMove);
document.addEventListener("touchmove", handleDragMove, { passive: false });
document.addEventListener("mouseup", handleDragEnd);
document.addEventListener("touchend", handleDragEnd);
}
function handleDragStart(e: MouseEvent | TouchEvent): void {
const header = e.target.closest(".panel-header") as HTMLElement;
const panel = header?.closest(".dockable-panel") as HTMLElement;
if (!panel) return;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
// Check if locked
if (panelState?.locked) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
state.dragState = {
panelId: panelId!,
startX: clientX,
startY: clientY,
currentX: clientX,
currentY: clientY,
isLocked: panelState?.locked || false,
};
panel.classList.add("dragging");
}
function handleDragMove(e: MouseEvent | TouchEvent): void {
if (!state.dragState) return;
const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
state.dragState.currentX = clientX;
state.dragState.currentY = clientY;
const panel = document.querySelector(
`[data-panel="${state.dragState.panelId}"]`,
);
const container = panel?.parentElement;
if (container) {
container.style.transform = `translateX(${clientX - state.dragState.startX}px)`;
}
}
function handleDragEnd(e: MouseEvent | TouchEvent): void {
if (!state.dragState) return;
const { panelId, currentX } = state.dragState;
const panel = document.querySelector(`[data-panel="${panelId}"]`);
const container = panel?.parentElement;
// Reset transform
container.style.transform = "";
panel?.classList.remove("dragging");
// Determine drop zone
const newSide = currentX < window.innerWidth / 2 ? "left" : "right";
const panelState = state.panels.get(panelId);
if (panelState) {
// Check if dropped in valid zone
const isValidDrop = newSide === "left" || newSide === "right";
if (isValidDrop) {
panelState.last_valid_side = panelState.side; // Save previous valid position
panelState.side = newSide;
} else {
// Snap back to last valid position
panelState.side = panelState.last_valid_side;
}
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
state.dragState = null;
}
// Setup window-shade (collapse/expand) handlers
function setupWindowShadeHandlers(): void {
document.querySelectorAll(".window-shade-toggle").forEach((btn) => {
btn.addEventListener("click", (e) => {
const panel = (e.target as HTMLElement).closest(
".dockable-panel",
) as HTMLElement;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
if (panelState) {
panelState.collapsed = !panelState.collapsed;
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
});
});
}
// Setup lock toggle handlers
function setupLockHandlers(): void {
document.querySelectorAll(".panel-lock").forEach((btn) => {
btn.addEventListener("click", (e) => {
const panel = (e.target as HTMLElement).closest(
".dockable-panel",
) as HTMLElement;
const panelId = panel.dataset.panel;
const panelState = state.panels.get(panelId);
if (panelState) {
panelState.locked = !panelState.locked;
applyPanelState(panelId, panelState);
savePanelState(panelId, panelState);
}
});
});
}
// Persist panel state to settings
async function savePanelState(
panelId: string,
panelState: PanelState,
): Promise<void> {
const settings = loadSettings();
settings.panel_layout[panelId as keyof typeof settings.panel_layout] =
panelState;
await saveSettings(settings);
}
export { initializePanelDockSystem, registerPanel, applyPanelState };