mirror of
https://github.com/usero2/obsidian-plugins-cbz-reader.git
synced 2026-07-22 07:43:42 +00:00
update
This commit is contained in:
parent
050926c120
commit
92e69c223f
9 changed files with 5 additions and 1080 deletions
|
|
@ -2,7 +2,10 @@
|
|||
|
||||
A powerful and fast native CBZ (Comic Book Archive) reader for Obsidian. Read your favorite mangas, comics, and graphic novels directly inside your Obsidian vault with a beautifully crafted, high-performance interface.
|
||||
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## ✨ Features
|
||||
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
import esbuild from "esbuild";
|
||||
import process from "process";
|
||||
import builtins from "builtin-modules";
|
||||
import fs from "fs";
|
||||
|
||||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: '/* eslint-disable */',
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
|
||||
// Patch main.js to remove script tags created by JSZip setImmediate polyfill
|
||||
let mainJs = fs.readFileSync("main.js", "utf8");
|
||||
mainJs = mainJs.replace(/createElement\(['"]script['"]\)/gi, 'createElement("div")');
|
||||
fs.writeFileSync("main.js", mainJs);
|
||||
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
}
|
||||
BIN
images/Obsidian_OXUlwf8IZ1.gif
Normal file
BIN
images/Obsidian_OXUlwf8IZ1.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 MiB |
|
|
@ -3,7 +3,7 @@
|
|||
"name": "CBZ Reader",
|
||||
"version": "1.0.3",
|
||||
"minAppVersion": "1.0.0",
|
||||
"description": "A powerful and fast native CBZ - Comic Book Archive reader",
|
||||
"description": "A powerful and fast native CBZ Comic Book Archive reader",
|
||||
"author": "usero2-endofday",
|
||||
"authorUrl": "https://github.com/usero2",
|
||||
"fundingUrl": {
|
||||
|
|
|
|||
28
package.json
28
package.json
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"name": "cbz-reader",
|
||||
"version": "1.0.3",
|
||||
"description": "Read CBZ files in Obsidian.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs production"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Author",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.29",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
479
plugin.js
479
plugin.js
|
|
@ -1,479 +0,0 @@
|
|||
const { Plugin, ItemView } = require('obsidian');
|
||||
|
||||
const CBZ_VIEW_TYPE = "cbz-view";
|
||||
|
||||
class CbzView extends ItemView {
|
||||
constructor(leaf) {
|
||||
super(leaf);
|
||||
this.file = null;
|
||||
this.zip = null;
|
||||
this.imageFiles = [];
|
||||
|
||||
this.observer = null;
|
||||
this.loadedImages = new Map();
|
||||
this.imgElements = new Map();
|
||||
|
||||
this.minimapObserver = null;
|
||||
this.canvasElements = new Map();
|
||||
this.minimapItems = new Map(); // Store to toggle 'is-active' class
|
||||
}
|
||||
|
||||
getViewType() { return CBZ_VIEW_TYPE; }
|
||||
getDisplayText() { return this.file ? this.file.name : "CBZ Reader"; }
|
||||
|
||||
async setState(state, result) {
|
||||
await super.setState(state, result);
|
||||
if (state.file) {
|
||||
const file = this.app.vault.getAbstractFileByPath(state.file);
|
||||
if (file) {
|
||||
await this.onLoadFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
file: this.file ? this.file.path : null
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.file = null;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
async onLoadFile(file) {
|
||||
this.file = file;
|
||||
this.contentEl.empty();
|
||||
|
||||
const layout = this.contentEl.createDiv({ cls: "cbz-reader-layout" });
|
||||
const mainView = layout.createDiv({ cls: "cbz-main-view" });
|
||||
const minimapView = layout.createDiv({ cls: "cbz-minimap" });
|
||||
|
||||
// --- Vertical Scrollbar Minimap Logic ---
|
||||
const minimapThumb = minimapView.createDiv({ cls: "cbz-minimap-thumb" });
|
||||
|
||||
let isDown = false;
|
||||
let startY;
|
||||
let startYScreen; // Add startYScreen
|
||||
let isDraggingThumb = false;
|
||||
|
||||
const updateThumbHeight = () => {
|
||||
let currentItemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) currentItemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
// Average height of a comic page is roughly 1.5x its width.
|
||||
// We use a constant reference height to prevent slider from jittering
|
||||
let h = (mainView.clientHeight / 1000) * currentItemHeight;
|
||||
return Math.max(20, Math.min(h, minimapView.clientHeight / 2));
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
const y = e.pageY;
|
||||
const walk = y - startY;
|
||||
if (Math.abs(walk) > 3) isDraggingThumb = true;
|
||||
|
||||
const tHeight = updateThumbHeight();
|
||||
const trackViewHeight = minimapView.clientHeight;
|
||||
|
||||
// Calculate new physical screen position of thumb relative to minimap view
|
||||
let yScreen = startYScreen + walk;
|
||||
yScreen = Math.max(0, Math.min(trackViewHeight - tHeight, yScreen));
|
||||
|
||||
// Calculate percentage p of track traveled
|
||||
let p = 0;
|
||||
if (trackViewHeight > tHeight) {
|
||||
p = yScreen / (trackViewHeight - tHeight);
|
||||
}
|
||||
|
||||
let currentItemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) currentItemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
const trackContentHeight = Math.max(minimapView.scrollHeight, (this.imageFiles ? this.imageFiles.length : 0) * currentItemHeight);
|
||||
const maxThumbTop = trackContentHeight - tHeight;
|
||||
|
||||
// Calculate exact position on the scrollable track
|
||||
const exactPosition = p * maxThumbTop;
|
||||
|
||||
// Update UI instantly
|
||||
minimapThumb.setCssStyles({
|
||||
top: exactPosition + 'px',
|
||||
height: tHeight + 'px'
|
||||
});
|
||||
minimapView.scrollTop = exactPosition - yScreen;
|
||||
|
||||
// Map exactPosition back to targetIndex and percentage for mainView
|
||||
const targetIndex = Math.floor(exactPosition / currentItemHeight);
|
||||
let itemPercentage = (exactPosition - (targetIndex * currentItemHeight)) / currentItemHeight;
|
||||
|
||||
if (!this.imageFiles || this.imageFiles.length === 0) return;
|
||||
const clampedIndex = Math.max(0, Math.min(targetIndex, this.imageFiles.length - 1));
|
||||
|
||||
if (targetIndex !== clampedIndex) {
|
||||
itemPercentage = targetIndex < 0 ? 0 : 1;
|
||||
}
|
||||
itemPercentage = Math.max(0, Math.min(1, itemPercentage));
|
||||
|
||||
const pageContainer = mainView.querySelector(`.cbz-page-container[data-index="${clampedIndex}"]`);
|
||||
if (pageContainer) {
|
||||
const parentRect = mainView.getBoundingClientRect();
|
||||
const rect = pageContainer.getBoundingClientRect();
|
||||
const absoluteOffsetTop = mainView.scrollTop + (rect.top - parentRect.top);
|
||||
|
||||
mainView.scrollTop = absoluteOffsetTop + (itemPercentage * rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (isDown) {
|
||||
isDown = false;
|
||||
minimapThumb.setCssStyles({ cursor: '' });
|
||||
setTimeout(() => { isDraggingThumb = false; }, 50);
|
||||
}
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
minimapThumb.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return; // only left click
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // prevent clicking the track below it
|
||||
isDown = true;
|
||||
isDraggingThumb = false;
|
||||
startY = e.pageY;
|
||||
|
||||
const rect = minimapThumb.getBoundingClientRect();
|
||||
const parentRect = minimapView.getBoundingClientRect();
|
||||
startYScreen = rect.top - parentRect.top;
|
||||
|
||||
minimapThumb.setCssStyles({ cursor: 'grabbing' });
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
// Track Click Logic
|
||||
minimapView.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target === minimapThumb) return; // handled by thumb
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const rect = minimapView.getBoundingClientRect();
|
||||
const clickY = e.clientY - rect.top + minimapView.scrollTop;
|
||||
|
||||
let itemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) itemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
const targetIndex = Math.floor(clickY / itemHeight);
|
||||
|
||||
// Can't scroll immediately if imageFiles is empty, but this only fires after load
|
||||
if (this.imageFiles && this.imageFiles.length > 0) {
|
||||
const clampedIndex = Math.max(0, Math.min(targetIndex, this.imageFiles.length - 1));
|
||||
const pageContainer = mainView.querySelector(`.cbz-page-container[data-index="${clampedIndex}"]`);
|
||||
if (pageContainer) {
|
||||
pageContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Smooth Wheel Logic ---
|
||||
minimapView.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Multiply deltaY so the minimap acts as a "fast scroll" area
|
||||
const multiplier = 4;
|
||||
mainView.scrollBy({ top: e.deltaY * multiplier, behavior: 'auto' });
|
||||
}, { passive: false });
|
||||
|
||||
mainView.createEl("h3", { text: `Loading ${file.name}...` });
|
||||
|
||||
try {
|
||||
const arrayBuffer = await this.app.vault.readBinary(file);
|
||||
this.zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
this.imageFiles = Object.values(this.zip.files)
|
||||
.filter(zipEntry => !zipEntry.dir && this.isImage(zipEntry.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
mainView.empty();
|
||||
|
||||
if (this.imageFiles.length === 0) {
|
||||
mainView.createEl("h3", { text: "No images found in this CBZ file." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Observer for main reading view
|
||||
this.observer = new IntersectionObserver(this.handleIntersection.bind(this), {
|
||||
root: mainView,
|
||||
rootMargin: "2000px 0px 2000px 0px",
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
// Observer for active page tracking removed - now handled synchronously in scroll event
|
||||
|
||||
|
||||
// Observer for minimap canvas loading
|
||||
this.minimapObserver = new IntersectionObserver(this.handleMinimapIntersection.bind(this), {
|
||||
root: minimapView,
|
||||
rootMargin: "500px 0px 500px 0px",
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
this.imageFiles.forEach((zipEntry, index) => {
|
||||
// Main view items
|
||||
const pageContainer = mainView.createDiv({ cls: "cbz-page-container" });
|
||||
pageContainer.dataset.index = index.toString();
|
||||
const img = pageContainer.createEl("img");
|
||||
this.imgElements.set(index, img);
|
||||
|
||||
this.observer.observe(pageContainer);
|
||||
|
||||
// Minimap items
|
||||
const minimapItem = minimapView.createDiv({ cls: "cbz-minimap-item" });
|
||||
minimapItem.dataset.index = index.toString();
|
||||
const canvas = minimapItem.createEl("canvas");
|
||||
this.canvasElements.set(index, canvas);
|
||||
this.minimapItems.set(index, minimapItem);
|
||||
|
||||
this.minimapObserver.observe(minimapItem);
|
||||
|
||||
// The track click logic handles minimapItem clicks now through event bubbling
|
||||
|
||||
});
|
||||
|
||||
// --- Continuous Smooth Minimap Sync ---
|
||||
mainView.addEventListener('scroll', () => {
|
||||
if (isDraggingThumb) return;
|
||||
|
||||
// Synchronously find the active page (first page with bottom > viewport top)
|
||||
const parentRect = mainView.getBoundingClientRect();
|
||||
const containers = mainView.querySelectorAll('.cbz-page-container');
|
||||
let activeContainer = null;
|
||||
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
const rect = containers[i].getBoundingClientRect();
|
||||
if (rect.bottom > parentRect.top) {
|
||||
activeContainer = containers[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeContainer) return;
|
||||
const indexStr = activeContainer.dataset.index;
|
||||
|
||||
// Also update the 'is-active' class on minimap items synchronously!
|
||||
this.minimapItems.forEach((item, idx) => {
|
||||
if (idx.toString() === indexStr) item.classList.add('is-active');
|
||||
else item.classList.remove('is-active');
|
||||
});
|
||||
|
||||
const rect = activeContainer.getBoundingClientRect();
|
||||
const offset = parentRect.top - rect.top;
|
||||
let percentage = offset / rect.height;
|
||||
percentage = Math.max(0, Math.min(1, percentage));
|
||||
|
||||
let currentItemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) currentItemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
const exactPosition = parseInt(indexStr) * currentItemHeight + (percentage * currentItemHeight);
|
||||
const tHeight = updateThumbHeight();
|
||||
minimapThumb.setCssStyles({
|
||||
height: tHeight + 'px',
|
||||
top: exactPosition + 'px'
|
||||
});
|
||||
|
||||
const trackContentHeight = Math.max(minimapView.scrollHeight, (this.imageFiles ? this.imageFiles.length : 0) * currentItemHeight);
|
||||
const trackViewHeight = minimapView.clientHeight;
|
||||
|
||||
let p = 0;
|
||||
const maxThumbTop = trackContentHeight - tHeight;
|
||||
if (maxThumbTop > 0) {
|
||||
p = exactPosition / maxThumbTop;
|
||||
p = Math.max(0, Math.min(1, p));
|
||||
}
|
||||
|
||||
const yScreen = p * (trackViewHeight - tHeight);
|
||||
const targetScrollTop = exactPosition - yScreen;
|
||||
|
||||
minimapView.scrollTop = targetScrollTop;
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to load CBZ", err);
|
||||
mainView.empty();
|
||||
mainView.createEl("h3", { text: `Error loading CBZ: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntersection(entries) {
|
||||
if (!this.zip) return;
|
||||
for (const entry of entries) {
|
||||
const indexStr = entry.target.dataset.index;
|
||||
if (!indexStr) continue;
|
||||
|
||||
const index = parseInt(indexStr);
|
||||
const img = this.imgElements.get(index);
|
||||
if (!img) continue;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
if (!this.loadedImages.has(index)) {
|
||||
this.loadImage(index, img);
|
||||
}
|
||||
} else {
|
||||
if (this.loadedImages.has(index)) {
|
||||
this.unloadImage(index, img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleMinimapIntersection(entries) {
|
||||
if (!this.zip) return;
|
||||
for (const entry of entries) {
|
||||
const indexStr = entry.target.dataset.index;
|
||||
if (!indexStr) continue;
|
||||
|
||||
const index = parseInt(indexStr);
|
||||
const canvas = this.canvasElements.get(index);
|
||||
if (!canvas) continue;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
if (!canvas.dataset.loaded) {
|
||||
this.loadMinimapCanvas(index, canvas);
|
||||
}
|
||||
}
|
||||
// Note: We don't unload canvas to save memory because drawn canvas uses very little memory
|
||||
// compared to an Object URL, and keeping them prevents re-extraction.
|
||||
}
|
||||
}
|
||||
|
||||
async loadMinimapCanvas(index, canvas) {
|
||||
canvas.dataset.loaded = "loading";
|
||||
try {
|
||||
const zipEntry = this.imageFiles[index];
|
||||
const blob = await zipEntry.async("blob");
|
||||
|
||||
const ext = zipEntry.name.split('.').pop();
|
||||
let mimeType = "image/jpeg";
|
||||
if (ext && ext.toLowerCase() === 'png') mimeType = "image/png";
|
||||
else if (ext && ext.toLowerCase() === 'webp') mimeType = "image/webp";
|
||||
else if (ext && ext.toLowerCase() === 'gif') mimeType = "image/gif";
|
||||
|
||||
const typedBlob = new Blob([blob], { type: mimeType });
|
||||
const url = URL.createObjectURL(typedBlob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Downscale to save memory. Width = 100px.
|
||||
const maxWidth = 100;
|
||||
const ratio = maxWidth / img.width;
|
||||
const width = maxWidth;
|
||||
const height = img.height * ratio;
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Immediately revoke object url so memory is freed!
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.dataset.loaded = "true";
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.dataset.loaded = "error";
|
||||
};
|
||||
img.src = url;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Failed to load minimap image at index ${index}`, err);
|
||||
canvas.dataset.loaded = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async loadImage(index, img) {
|
||||
this.loadedImages.set(index, "loading");
|
||||
try {
|
||||
const zipEntry = this.imageFiles[index];
|
||||
const blob = await zipEntry.async("blob");
|
||||
|
||||
const ext = zipEntry.name.split('.').pop();
|
||||
let mimeType = "image/jpeg";
|
||||
if (ext && ext.toLowerCase() === 'png') mimeType = "image/png";
|
||||
else if (ext && ext.toLowerCase() === 'webp') mimeType = "image/webp";
|
||||
else if (ext && ext.toLowerCase() === 'gif') mimeType = "image/gif";
|
||||
|
||||
const typedBlob = new Blob([blob], { type: mimeType });
|
||||
const url = URL.createObjectURL(typedBlob);
|
||||
|
||||
this.loadedImages.set(index, url);
|
||||
img.src = url;
|
||||
|
||||
if (img.parentElement) {
|
||||
img.parentElement.setCssStyles({ minHeight: 'auto' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load image at index ${index}`, err);
|
||||
this.loadedImages.delete(index);
|
||||
}
|
||||
}
|
||||
|
||||
unloadImage(index, img) {
|
||||
const url = this.loadedImages.get(index);
|
||||
if (url && url !== "loading") {
|
||||
URL.revokeObjectURL(url);
|
||||
img.removeAttribute("src");
|
||||
if (img.parentElement) {
|
||||
img.parentElement.setCssStyles({ minHeight: `${img.height || 800}px` });
|
||||
}
|
||||
}
|
||||
this.loadedImages.delete(index);
|
||||
}
|
||||
|
||||
isImage(filename) {
|
||||
const ext = filename.split('.').pop();
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'].includes(ext ? ext.toLowerCase() : '');
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
if (this.minimapObserver) {
|
||||
this.minimapObserver.disconnect();
|
||||
this.minimapObserver = null;
|
||||
}
|
||||
for (const url of Array.from(this.loadedImages.values())) {
|
||||
if (url !== "loading") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
this.loadedImages.clear();
|
||||
this.imgElements.clear();
|
||||
this.canvasElements.clear();
|
||||
this.minimapItems.clear();
|
||||
this.zip = null;
|
||||
this.imageFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
class CBZReaderPlugin extends Plugin {
|
||||
async onload() {
|
||||
console.log('loading cbz-reader plugin');
|
||||
this.registerView(CBZ_VIEW_TYPE, (leaf) => new CbzView(leaf));
|
||||
this.registerExtensions(["cbz"], CBZ_VIEW_TYPE);
|
||||
}
|
||||
onunload() {
|
||||
console.log('unloading cbz-reader plugin');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CBZReaderPlugin;
|
||||
480
src/CbzView.ts
480
src/CbzView.ts
|
|
@ -1,480 +0,0 @@
|
|||
import { ItemView, WorkspaceLeaf, TFile } from "obsidian";
|
||||
import * as JSZip from "jszip";
|
||||
|
||||
export const CBZ_VIEW_TYPE = "cbz-view";
|
||||
|
||||
export class CbzView extends ItemView {
|
||||
file: any = null;
|
||||
zip: any = null;
|
||||
imageFiles: any[] = [];
|
||||
observer: any = null;
|
||||
minimapObserver: any = null;
|
||||
|
||||
loadedImages: Map<number, string> = new Map();
|
||||
imgElements: Map<number, HTMLImageElement> = new Map();
|
||||
canvasElements: Map<number, HTMLCanvasElement> = new Map();
|
||||
minimapItems: Map<number, HTMLDivElement> = new Map();
|
||||
|
||||
constructor(leaf: WorkspaceLeaf) {
|
||||
super(leaf);
|
||||
this.file = null;
|
||||
this.zip = null;
|
||||
this.imageFiles = [];
|
||||
|
||||
this.observer = null;
|
||||
this.loadedImages = new Map();
|
||||
this.imgElements = new Map();
|
||||
|
||||
this.minimapObserver = null;
|
||||
this.canvasElements = new Map();
|
||||
this.minimapItems = new Map(); // Store to toggle 'is-active' class
|
||||
}
|
||||
|
||||
getViewType() { return CBZ_VIEW_TYPE; }
|
||||
getDisplayText() { return this.file ? this.file.name : "CBZ Reader"; }
|
||||
|
||||
async setState(state, result) {
|
||||
await super.setState(state, result);
|
||||
if (state.file) {
|
||||
const file = this.app.vault.getAbstractFileByPath(state.file);
|
||||
if (file) {
|
||||
await this.onLoadFile(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getState() {
|
||||
return {
|
||||
file: this.file ? this.file.path : null
|
||||
};
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.file = null;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
|
||||
async onLoadFile(file: TFile) {
|
||||
this.file = file;
|
||||
this.contentEl.empty();
|
||||
|
||||
const layout = this.contentEl.createDiv({ cls: "cbz-reader-layout" });
|
||||
const mainView = layout.createDiv({ cls: "cbz-main-view" });
|
||||
const minimapView = layout.createDiv({ cls: "cbz-minimap" });
|
||||
|
||||
// --- Vertical Scrollbar Minimap Logic ---
|
||||
const minimapThumb = minimapView.createDiv({ cls: "cbz-minimap-thumb" });
|
||||
|
||||
let isDown = false;
|
||||
let startY;
|
||||
let startYScreen; // Add startYScreen
|
||||
let isDraggingThumb = false;
|
||||
|
||||
const updateThumbHeight = () => {
|
||||
let currentItemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) currentItemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
// Average height of a comic page is roughly 1.5x its width.
|
||||
// We use a constant reference height to prevent slider from jittering
|
||||
let h = (mainView.clientHeight / 1000) * currentItemHeight;
|
||||
return Math.max(20, Math.min(h, minimapView.clientHeight / 2));
|
||||
};
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
const y = e.pageY;
|
||||
const walk = y - startY;
|
||||
if (Math.abs(walk) > 3) isDraggingThumb = true;
|
||||
|
||||
const tHeight = updateThumbHeight();
|
||||
const trackViewHeight = minimapView.clientHeight;
|
||||
|
||||
// Calculate new physical screen position of thumb relative to minimap view
|
||||
let yScreen = startYScreen + walk;
|
||||
yScreen = Math.max(0, Math.min(trackViewHeight - tHeight, yScreen));
|
||||
|
||||
// Calculate percentage p of track traveled
|
||||
let p = 0;
|
||||
if (trackViewHeight > tHeight) {
|
||||
p = yScreen / (trackViewHeight - tHeight);
|
||||
}
|
||||
|
||||
let currentItemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) currentItemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
const trackContentHeight = Math.max(minimapView.scrollHeight, (this.imageFiles ? this.imageFiles.length : 0) * currentItemHeight);
|
||||
const maxThumbTop = trackContentHeight - tHeight;
|
||||
|
||||
// Calculate exact position on the scrollable track
|
||||
const exactPosition = p * maxThumbTop;
|
||||
|
||||
// Update UI instantly
|
||||
minimapThumb.setCssStyles({
|
||||
top: exactPosition + 'px',
|
||||
height: tHeight + 'px'
|
||||
});
|
||||
minimapView.scrollTop = exactPosition - yScreen;
|
||||
|
||||
// Map exactPosition back to targetIndex and percentage for mainView
|
||||
const targetIndex = Math.floor(exactPosition / currentItemHeight);
|
||||
let itemPercentage = (exactPosition - (targetIndex * currentItemHeight)) / currentItemHeight;
|
||||
|
||||
if (!this.imageFiles || this.imageFiles.length === 0) return;
|
||||
const clampedIndex = Math.max(0, Math.min(targetIndex, this.imageFiles.length - 1));
|
||||
|
||||
if (targetIndex !== clampedIndex) {
|
||||
itemPercentage = targetIndex < 0 ? 0 : 1;
|
||||
}
|
||||
itemPercentage = Math.max(0, Math.min(1, itemPercentage));
|
||||
|
||||
const pageContainer = mainView.querySelector(`.cbz-page-container[data-index="${clampedIndex}"]`);
|
||||
if (pageContainer) {
|
||||
const parentRect = mainView.getBoundingClientRect();
|
||||
const rect = pageContainer.getBoundingClientRect();
|
||||
const absoluteOffsetTop = mainView.scrollTop + (rect.top - parentRect.top);
|
||||
|
||||
mainView.scrollTop = absoluteOffsetTop + (itemPercentage * rect.height);
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
if (isDown) {
|
||||
isDown = false;
|
||||
minimapThumb.setCssStyles({ cursor: '' });
|
||||
setTimeout(() => { isDraggingThumb = false; }, 50);
|
||||
}
|
||||
document.removeEventListener('mousemove', onMouseMove);
|
||||
document.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
minimapThumb.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return; // only left click
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // prevent clicking the track below it
|
||||
isDown = true;
|
||||
isDraggingThumb = false;
|
||||
startY = e.pageY;
|
||||
|
||||
const rect = minimapThumb.getBoundingClientRect();
|
||||
const parentRect = minimapView.getBoundingClientRect();
|
||||
startYScreen = rect.top - parentRect.top;
|
||||
|
||||
minimapThumb.setCssStyles({ cursor: 'grabbing' });
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', onMouseUp);
|
||||
});
|
||||
|
||||
// Track Click Logic
|
||||
minimapView.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
if (e.target === minimapThumb) return; // handled by thumb
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
const rect = minimapView.getBoundingClientRect();
|
||||
const clickY = e.clientY - rect.top + minimapView.scrollTop;
|
||||
|
||||
let itemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) itemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
const targetIndex = Math.floor(clickY / itemHeight);
|
||||
|
||||
// Can't scroll immediately if imageFiles is empty, but this only fires after load
|
||||
if (this.imageFiles && this.imageFiles.length > 0) {
|
||||
const clampedIndex = Math.max(0, Math.min(targetIndex, this.imageFiles.length - 1));
|
||||
const pageContainer = mainView.querySelector(`.cbz-page-container[data-index="${clampedIndex}"]`);
|
||||
if (pageContainer) {
|
||||
pageContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- Smooth Wheel Logic ---
|
||||
minimapView.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Multiply deltaY so the minimap acts as a "fast scroll" area
|
||||
const multiplier = 4;
|
||||
mainView.scrollBy({ top: e.deltaY * multiplier, behavior: 'auto' });
|
||||
}, { passive: false });
|
||||
|
||||
mainView.createEl("h3", { text: `Loading ${file.name}...` });
|
||||
|
||||
try {
|
||||
const arrayBuffer = await this.app.vault.readBinary(file);
|
||||
this.zip = await JSZip.loadAsync(arrayBuffer);
|
||||
|
||||
this.imageFiles = Object.values(this.zip.files)
|
||||
.filter(zipEntry => !zipEntry.dir && this.isImage(zipEntry.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }));
|
||||
|
||||
mainView.empty();
|
||||
|
||||
if (this.imageFiles.length === 0) {
|
||||
mainView.createEl("h3", { text: "No images found in this CBZ file." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Observer for main reading view
|
||||
this.observer = new IntersectionObserver(this.handleIntersection.bind(this), {
|
||||
root: mainView,
|
||||
rootMargin: "2000px 0px 2000px 0px",
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
// Observer for active page tracking removed - now handled synchronously in scroll event
|
||||
|
||||
|
||||
// Observer for minimap canvas loading
|
||||
this.minimapObserver = new IntersectionObserver(this.handleMinimapIntersection.bind(this), {
|
||||
root: minimapView,
|
||||
rootMargin: "500px 0px 500px 0px",
|
||||
threshold: 0
|
||||
});
|
||||
|
||||
this.imageFiles.forEach((zipEntry, index) => {
|
||||
// Main view items
|
||||
const pageContainer = mainView.createDiv({ cls: "cbz-page-container" });
|
||||
pageContainer.dataset.index = index.toString();
|
||||
const img = pageContainer.createEl("img");
|
||||
this.imgElements.set(index, img);
|
||||
|
||||
this.observer.observe(pageContainer);
|
||||
|
||||
// Minimap items
|
||||
const minimapItem = minimapView.createDiv({ cls: "cbz-minimap-item" });
|
||||
minimapItem.dataset.index = index.toString();
|
||||
const canvas = minimapItem.createEl("canvas");
|
||||
this.canvasElements.set(index, canvas);
|
||||
this.minimapItems.set(index, minimapItem);
|
||||
|
||||
this.minimapObserver.observe(minimapItem);
|
||||
|
||||
// The track click logic handles minimapItem clicks now through event bubbling
|
||||
|
||||
});
|
||||
|
||||
// --- Continuous Smooth Minimap Sync ---
|
||||
mainView.addEventListener('scroll', () => {
|
||||
if (isDraggingThumb) return;
|
||||
|
||||
// Synchronously find the active page (first page with bottom > viewport top)
|
||||
const parentRect = mainView.getBoundingClientRect();
|
||||
const containers = mainView.querySelectorAll('.cbz-page-container');
|
||||
let activeContainer = null;
|
||||
|
||||
for (let i = 0; i < containers.length; i++) {
|
||||
const rect = containers[i].getBoundingClientRect();
|
||||
if (rect.bottom > parentRect.top) {
|
||||
activeContainer = containers[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeContainer) return;
|
||||
const indexStr = activeContainer.dataset.index;
|
||||
|
||||
// Also update the 'is-active' class on minimap items synchronously!
|
||||
this.minimapItems.forEach((item, idx) => {
|
||||
if (idx.toString() === indexStr) item.classList.add('is-active');
|
||||
else item.classList.remove('is-active');
|
||||
});
|
||||
|
||||
const rect = activeContainer.getBoundingClientRect();
|
||||
const offset = parentRect.top - rect.top;
|
||||
let percentage = offset / rect.height;
|
||||
percentage = Math.max(0, Math.min(1, percentage));
|
||||
|
||||
let currentItemHeight = 145;
|
||||
const firstItem = this.minimapItems.get(0);
|
||||
if (firstItem) currentItemHeight = firstItem.offsetHeight + 5;
|
||||
|
||||
const exactPosition = parseInt(indexStr) * currentItemHeight + (percentage * currentItemHeight);
|
||||
const tHeight = updateThumbHeight();
|
||||
minimapThumb.setCssStyles({
|
||||
height: tHeight + 'px',
|
||||
top: exactPosition + 'px'
|
||||
});
|
||||
|
||||
const trackContentHeight = Math.max(minimapView.scrollHeight, (this.imageFiles ? this.imageFiles.length : 0) * currentItemHeight);
|
||||
const trackViewHeight = minimapView.clientHeight;
|
||||
|
||||
let p = 0;
|
||||
const maxThumbTop = trackContentHeight - tHeight;
|
||||
if (maxThumbTop > 0) {
|
||||
p = exactPosition / maxThumbTop;
|
||||
p = Math.max(0, Math.min(1, p));
|
||||
}
|
||||
|
||||
const yScreen = p * (trackViewHeight - tHeight);
|
||||
const targetScrollTop = exactPosition - yScreen;
|
||||
|
||||
minimapView.scrollTop = targetScrollTop;
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to load CBZ", err);
|
||||
mainView.empty();
|
||||
mainView.createEl("h3", { text: `Error loading CBZ: ${err.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
async handleIntersection(entries: any[]) {
|
||||
if (!this.zip) return;
|
||||
for (const entry of entries) {
|
||||
const indexStr = entry.target.dataset.index;
|
||||
if (!indexStr) continue;
|
||||
|
||||
const index = parseInt(indexStr);
|
||||
const img = this.imgElements.get(index);
|
||||
if (!img) continue;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
if (!this.loadedImages.has(index)) {
|
||||
this.loadImage(index, img);
|
||||
}
|
||||
} else {
|
||||
if (this.loadedImages.has(index)) {
|
||||
this.unloadImage(index, img);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleMinimapIntersection(entries: any[]) {
|
||||
if (!this.zip) return;
|
||||
for (const entry of entries) {
|
||||
const indexStr = entry.target.dataset.index;
|
||||
if (!indexStr) continue;
|
||||
|
||||
const index = parseInt(indexStr);
|
||||
const canvas = this.canvasElements.get(index);
|
||||
if (!canvas) continue;
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
if (!canvas.dataset.loaded) {
|
||||
this.loadMinimapCanvas(index, canvas);
|
||||
}
|
||||
}
|
||||
// Note: We don't unload canvas to save memory because drawn canvas uses very little memory
|
||||
// compared to an Object URL, and keeping them prevents re-extraction.
|
||||
}
|
||||
}
|
||||
|
||||
async loadMinimapCanvas(index: number, canvas: any) {
|
||||
canvas.dataset.loaded = "loading";
|
||||
try {
|
||||
const zipEntry = this.imageFiles[index];
|
||||
const blob = await zipEntry.async("blob");
|
||||
|
||||
const ext = zipEntry.name.split('.').pop();
|
||||
let mimeType = "image/jpeg";
|
||||
if (ext && ext.toLowerCase() === 'png') mimeType = "image/png";
|
||||
else if (ext && ext.toLowerCase() === 'webp') mimeType = "image/webp";
|
||||
else if (ext && ext.toLowerCase() === 'gif') mimeType = "image/gif";
|
||||
|
||||
const typedBlob = new Blob([blob], { type: mimeType });
|
||||
const url = URL.createObjectURL(typedBlob);
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
// Downscale to save memory. Width = 100px.
|
||||
const maxWidth = 100;
|
||||
const ratio = maxWidth / img.width;
|
||||
const width = maxWidth;
|
||||
const height = img.height * ratio;
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Immediately revoke object url so memory is freed!
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.dataset.loaded = "true";
|
||||
};
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.dataset.loaded = "error";
|
||||
};
|
||||
img.src = url;
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Failed to load minimap image at index ${index}`, err);
|
||||
canvas.dataset.loaded = "error";
|
||||
}
|
||||
}
|
||||
|
||||
async loadImage(index: number, img: any) {
|
||||
this.loadedImages.set(index, "loading");
|
||||
try {
|
||||
const zipEntry = this.imageFiles[index];
|
||||
const blob = await zipEntry.async("blob");
|
||||
|
||||
const ext = zipEntry.name.split('.').pop();
|
||||
let mimeType = "image/jpeg";
|
||||
if (ext && ext.toLowerCase() === 'png') mimeType = "image/png";
|
||||
else if (ext && ext.toLowerCase() === 'webp') mimeType = "image/webp";
|
||||
else if (ext && ext.toLowerCase() === 'gif') mimeType = "image/gif";
|
||||
|
||||
const typedBlob = new Blob([blob], { type: mimeType });
|
||||
const url = URL.createObjectURL(typedBlob);
|
||||
|
||||
this.loadedImages.set(index, url);
|
||||
img.src = url;
|
||||
|
||||
if (img.parentElement) {
|
||||
img.parentElement.setCssStyles({ minHeight: 'auto' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load image at index ${index}`, err);
|
||||
this.loadedImages.delete(index);
|
||||
}
|
||||
}
|
||||
|
||||
unloadImage(index: number, img: any) {
|
||||
const url = this.loadedImages.get(index);
|
||||
if (url && url !== "loading") {
|
||||
URL.revokeObjectURL(url);
|
||||
img.removeAttribute("src");
|
||||
if (img.parentElement) {
|
||||
img.parentElement.setCssStyles({ minHeight: `${img.height || 800}px` });
|
||||
}
|
||||
}
|
||||
this.loadedImages.delete(index);
|
||||
}
|
||||
|
||||
isImage(filename: string) {
|
||||
const ext = filename.split('.').pop();
|
||||
return ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'].includes(ext ? ext.toLowerCase() : '');
|
||||
}
|
||||
|
||||
async onClose() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect();
|
||||
this.observer = null;
|
||||
}
|
||||
if (this.minimapObserver) {
|
||||
this.minimapObserver.disconnect();
|
||||
this.minimapObserver = null;
|
||||
}
|
||||
for (const url of Array.from(this.loadedImages.values())) {
|
||||
if (url !== "loading") {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
this.loadedImages.clear();
|
||||
this.imgElements.clear();
|
||||
this.canvasElements.clear();
|
||||
this.minimapItems.clear();
|
||||
this.zip = null;
|
||||
this.imageFiles = [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
19
src/main.ts
19
src/main.ts
|
|
@ -1,19 +0,0 @@
|
|||
import { Plugin } from 'obsidian';
|
||||
import { CBZ_VIEW_TYPE, CbzView } from './CbzView';
|
||||
|
||||
export default class CBZReaderPlugin extends Plugin {
|
||||
async onload() {
|
||||
console.log('loading cbz-reader plugin');
|
||||
|
||||
this.registerView(
|
||||
CBZ_VIEW_TYPE,
|
||||
(leaf) => new CbzView(leaf)
|
||||
);
|
||||
|
||||
this.registerExtensions(["cbz"], CBZ_VIEW_TYPE);
|
||||
}
|
||||
|
||||
onunload() {
|
||||
console.log('unloading cbz-reader plugin');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
"inlineSources": true,
|
||||
"module": "ESNext",
|
||||
"target": "ES6",
|
||||
"allowJs": true,
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"isolatedModules": true,
|
||||
"strictNullChecks": true,
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES5",
|
||||
"ES6",
|
||||
"ES7"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue