feat: Add missing feature files for build
Create panel-dock-system.ts and navigator-panel.ts in features/ directory with proper init() implementations. Copy offline-manager and reading-speed-tracker to features/ as well.
This commit is contained in:
@@ -0,0 +1,133 @@
|
|||||||
|
// 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";
|
||||||
|
|
||||||
|
export function init(context: ReaderContext): void {
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Offline manager for PWA functionality
|
||||||
|
// Feature Registration Pattern implementation
|
||||||
|
|
||||||
|
import type { ReaderContext } from "./core/reader-context";
|
||||||
|
|
||||||
|
export function init(context: ReaderContext): void {
|
||||||
|
registerServiceWorker();
|
||||||
|
|
||||||
|
window.addEventListener("online", () => {
|
||||||
|
const isOnline = checkOnlineStatus();
|
||||||
|
if (isOnline) {
|
||||||
|
context.events.emit("offline:online", {});
|
||||||
|
syncPendingChanges(context);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("offline", () => {
|
||||||
|
context.events.emit("offline:offline", {});
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("offline:check", () => {
|
||||||
|
const isOnline = checkOnlineStatus();
|
||||||
|
context.events.emit("offline:status", { isOnline });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerServiceWorker(): void {
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register("/static/service-worker.js")
|
||||||
|
.then((registration) => {
|
||||||
|
console.log("Service worker registered:", registration);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Service worker registration failed:", error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkOnlineStatus(): boolean {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.onLine) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPendingChanges(context: ReaderContext): void {
|
||||||
|
context.events.emit("offline:sync", {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
// Modular dockable panel system - handles drag, lock, snap-back, window-shade
|
||||||
|
// Feature Registration Pattern implementation
|
||||||
|
|
||||||
|
import type { ReaderContext } from "../core/reader-context";
|
||||||
|
|
||||||
|
export function init(context: ReaderContext): void {
|
||||||
|
const dockZones: DockZone[] = [
|
||||||
|
{ side: "left", x: 0, width: 400, height: window.innerHeight },
|
||||||
|
{
|
||||||
|
side: "right",
|
||||||
|
x: window.innerWidth - 400,
|
||||||
|
width: 400,
|
||||||
|
height: window.innerHeight,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const state: PanelDockState = {
|
||||||
|
panels: new Map(),
|
||||||
|
dragState: null,
|
||||||
|
dockZones,
|
||||||
|
};
|
||||||
|
|
||||||
|
context.events.on("panels:initialize", async () => {
|
||||||
|
const settings = await loadSettings();
|
||||||
|
for (const [panelId, panelState] of Object.entries(settings.panel_layout)) {
|
||||||
|
state.panels.set(panelId, panelState as PanelState);
|
||||||
|
createPanel(context, panelId, panelState as PanelState);
|
||||||
|
}
|
||||||
|
setupGlobalDragHandlers(context, state);
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("panel:toggle", (detail: { panelId: string }) => {
|
||||||
|
const panelState = state.panels.get(detail.panelId);
|
||||||
|
if (panelState) {
|
||||||
|
panelState.visible = !panelState.visible;
|
||||||
|
updatePanelVisibility(context, detail.panelId, panelState.visible);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("panel:collapse", (detail: { panelId: string }) => {
|
||||||
|
const panelState = state.panels.get(detail.panelId);
|
||||||
|
if (panelState) {
|
||||||
|
panelState.collapsed = !panelState.collapsed;
|
||||||
|
updatePanelCollapsed(context, detail.panelId, panelState.collapsed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("panel:move", (detail: { panelId: string; side: "left" | "right" }) => {
|
||||||
|
movePanelToSide(context, state, detail.panelId, detail.side);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PanelState {
|
||||||
|
side: "left" | "right";
|
||||||
|
visible: boolean;
|
||||||
|
collapsed: boolean;
|
||||||
|
width_px: number;
|
||||||
|
order: number;
|
||||||
|
locked: boolean;
|
||||||
|
last_valid_side: "left" | "right";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettings(): Promise<any> {
|
||||||
|
const response = await fetch("/readers/settings");
|
||||||
|
if (!response.ok) return {};
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPanel(context: ReaderContext, panelId: string, panelState: PanelState): void {
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.id = `panel-${panelId}`;
|
||||||
|
panel.className = `reader-panel panel-${panelState.side}`;
|
||||||
|
panel.dataset.panelId = panelId;
|
||||||
|
panel.style.width = `${panelState.width_px}px`;
|
||||||
|
|
||||||
|
if (!panelState.visible) {
|
||||||
|
panel.classList.add("panel-hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (panelState.collapsed) {
|
||||||
|
panel.classList.add("panel-collapsed");
|
||||||
|
}
|
||||||
|
|
||||||
|
context.elements.readerContent.appendChild(panel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanelVisibility(context: ReaderContext, panelId: string, visible: boolean): void {
|
||||||
|
const panel = document.getElementById(`panel-${panelId}`);
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.toggle("panel-hidden", !visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePanelCollapsed(context: ReaderContext, panelId: string, collapsed: boolean): void {
|
||||||
|
const panel = document.getElementById(`panel-${panelId}`);
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.toggle("panel-collapsed", collapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupGlobalDragHandlers(context: ReaderContext, state: PanelDockState): void {
|
||||||
|
document.addEventListener("mousedown", (e) => {
|
||||||
|
const panelHeader = (e.target as HTMLElement).closest(".panel-header");
|
||||||
|
if (panelHeader) {
|
||||||
|
const panelId = (panelHeader as HTMLElement).dataset.panelId;
|
||||||
|
if (panelId) {
|
||||||
|
const panelState = state.panels.get(panelId);
|
||||||
|
if (panelState && !panelState.locked) {
|
||||||
|
state.dragState = {
|
||||||
|
panelId,
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
currentX: e.clientX,
|
||||||
|
currentY: e.clientY,
|
||||||
|
isLocked: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", (e) => {
|
||||||
|
if (!state.dragState) return;
|
||||||
|
state.dragState.currentX = e.clientX;
|
||||||
|
state.dragState.currentY = e.clientY;
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mouseup", () => {
|
||||||
|
if (state.dragState) {
|
||||||
|
const nearestZone = findNearestDockZone(state.dragState.currentX, state.dockZones);
|
||||||
|
if (nearestZone) {
|
||||||
|
movePanelToSide(context, state, state.dragState.panelId, nearestZone.side);
|
||||||
|
}
|
||||||
|
state.dragState = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNearestDockZone(x: number, zones: DockZone[]): DockZone | null {
|
||||||
|
let nearest: DockZone | null = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
for (const zone of zones) {
|
||||||
|
const distance = Math.abs(x - (zone.x + zone.width / 2));
|
||||||
|
if (distance < minDistance) {
|
||||||
|
minDistance = distance;
|
||||||
|
nearest = zone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function movePanelToSide(context: ReaderContext, state: PanelDockState, panelId: string, side: "left" | "right"): void {
|
||||||
|
const panelState = state.panels.get(panelId);
|
||||||
|
if (!panelState) return;
|
||||||
|
|
||||||
|
panelState.side = side;
|
||||||
|
panelState.last_valid_side = side;
|
||||||
|
|
||||||
|
const panel = document.getElementById(`panel-${panelId}`);
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove("panel-left", "panel-right");
|
||||||
|
panel.classList.add(`panel-${side}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
context.events.emit("panel:moved", { panelId, side });
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Track reading speed and update database
|
||||||
|
// Feature Registration Pattern implementation
|
||||||
|
|
||||||
|
import type { ReaderContext } from "./core/reader-context";
|
||||||
|
|
||||||
|
export function init(context: ReaderContext): void {
|
||||||
|
let state: ReadingSpeedTrackerState | null = null;
|
||||||
|
|
||||||
|
context.events.on("reader:loaded", (detail: { mediaItemId: string }) => {
|
||||||
|
state = createReadingSpeedTracker(detail.mediaItemId);
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("reading-session:start", () => {
|
||||||
|
if (state) {
|
||||||
|
startReadingSession(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("page-changed", () => {
|
||||||
|
if (state) {
|
||||||
|
recordPageTurn(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("words-read", (detail: { wordCount: number }) => {
|
||||||
|
if (state) {
|
||||||
|
recordWordsRead(state, detail.wordCount);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("reading-session:end", async () => {
|
||||||
|
if (state) {
|
||||||
|
await syncReadingSpeed(state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
context.events.on("reader:unload", async () => {
|
||||||
|
if (state) {
|
||||||
|
await syncReadingSpeed(state);
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReadingSpeedTrackerState {
|
||||||
|
startTime: number | null;
|
||||||
|
pagesRead: number;
|
||||||
|
wordsRead: number;
|
||||||
|
lastSync: number;
|
||||||
|
mediaItemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadingSpeedTracker(
|
||||||
|
mediaItemId: string,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
return {
|
||||||
|
startTime: null,
|
||||||
|
pagesRead: 0,
|
||||||
|
wordsRead: 0,
|
||||||
|
lastSync: Date.now(),
|
||||||
|
mediaItemId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startReadingSession(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
state.startTime = Date.now();
|
||||||
|
state.pagesRead = 0;
|
||||||
|
state.wordsRead = 0;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPageTurn(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
if (!state.startTime) return state;
|
||||||
|
|
||||||
|
state.pagesRead += 1;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (state.pagesRead % 5 === 0 || now - state.lastSync > 5 * 60 * 1000) {
|
||||||
|
syncReadingSpeed(state);
|
||||||
|
state.lastSync = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordWordsRead(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
wordCount: number,
|
||||||
|
): ReadingSpeedTrackerState {
|
||||||
|
state.wordsRead += wordCount;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncReadingSpeed(
|
||||||
|
state: ReadingSpeedTrackerState,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!state.startTime) return;
|
||||||
|
|
||||||
|
const minutesElapsed = (Date.now() - state.startTime) / (1000 * 60);
|
||||||
|
const pagesPerMinute = state.pagesRead / minutesElapsed;
|
||||||
|
const wordsPerMinute = state.wordsRead / minutesElapsed;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
await fetch(`/readers/${state.mediaItemId}/reading-speed`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
pages_per_minute: pagesPerMinute,
|
||||||
|
words_per_minute: wordsPerMinute,
|
||||||
|
pages_read: state.pagesRead,
|
||||||
|
total_reading_minutes: minutesElapsed,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to sync reading speed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user