Add panel navigation support to fixed-layout renderer

Implement comprehensive panel-aware navigation for manga, comics,
and other fixed-layout content with automatic panel detection.

Core features:
- Panel mode toggle via 'panel-mode' attribute
- Touch gesture support (swipe, tap)
- Visual panel overlay with SVG highlighting
- Auto-zoom to center and fit each panel
- Seamless integration with existing page navigation

Panel detection integration:
- Initialize PanelDetector on component construction
- Detect panels when entering panel mode
- Cache detection results per page
- Support re-detection via force flag

Panel navigation:
- nextPanel(): advance to next panel, wrapping to next page
- prevPanel(): go to previous panel, wrapping to previous page
- Auto-enter panel mode on first panel navigation
- Visual feedback with current panel highlighting

Touch support (previously missing from fixed-layout):
- touchstart: record initial position and timestamp
- touchmove: prevent default for significant movement
- touchend: velocity-based swipe detection
- Panel mode: tap to toggle overlay, swipe to navigate

Visual overlay:
- SVG rectangles showing detected panels
- Highlight current panel in orange (#ff6b35)
- Dim other panels in semi-transparent white
- Pointer-events none for non-blocking overlay

Zoom behavior:
- Calculate scale to fit panel within viewport
- Center panel with scroll positioning
- Apply via existing zoom attribute mechanism
- Re-render overlay after zoom

Internal methods:
- #nextPage() / #prevPage(): bypass panel-mode check
- #enterPanelMode() / #exitPanelMode(): mode management
- #showPanelOverlay() / #hidePanelOverlay(): overlay management
- #zoomToPanel(): auto-center and scale to panel
- #addTouchSupport(): attach touch event listeners
- #onTouchStart/Move/End: touch gesture handling
- #handlePanelTouch: panel-mode specific touch logic

Public API:
- panelCount: number of detected panels
- currentPanelIndex: current panel index
- togglePanelMode(): enter/exit panel mode

Observer attributes:
- 'zoom': existing zoom support
- 'panel-mode': new panel mode attribute

This implementation brings fixed-layout renderer to feature parity
with paginator.js regarding touch support while adding unique
panel-aware navigation capabilities.
This commit is contained in:
2026-04-13 16:43:40 -04:00
parent 36537e3728
commit 3373506ed2
+607 -278
View File
@@ -1,319 +1,648 @@
const parseViewport = str => str const parseViewport = (str) =>
str
?.split(/[,;\s]/) // NOTE: technically, only the comma is valid ?.split(/[,;\s]/) // NOTE: technically, only the comma is valid
?.filter(x => x) ?.filter((x) => x)
?.map(x => x.split('=').map(x => x.trim())) ?.map((x) => x.split("=").map((x) => x.trim()));
const getViewport = (doc, viewport) => { const getViewport = (doc, viewport) => {
// use `viewBox` for SVG // use `viewBox` for SVG
if (doc.documentElement.localName === 'svg') { if (doc.documentElement.localName === "svg") {
const [, , width, height] = doc.documentElement const [, , width, height] =
.getAttribute('viewBox')?.split(/\s/) ?? [] doc.documentElement.getAttribute("viewBox")?.split(/\s/) ?? [];
return { width, height } return { width, height };
} }
// get `viewport` `meta` element // get `viewport` `meta` element
const meta = parseViewport(doc.querySelector('meta[name="viewport"]') const meta = parseViewport(
?.getAttribute('content')) doc.querySelector('meta[name="viewport"]')?.getAttribute("content"),
if (meta) return Object.fromEntries(meta) );
if (meta) return Object.fromEntries(meta);
// fallback to book's viewport // fallback to book's viewport
if (typeof viewport === 'string') return parseViewport(viewport) if (typeof viewport === "string") return parseViewport(viewport);
if (viewport?.width && viewport.height) return viewport if (viewport?.width && viewport.height) return viewport;
// if no viewport (possibly with image directly in spine), get image size // if no viewport (possibly with image directly in spine), get image size
const img = doc.querySelector('img') const img = doc.querySelector("img");
if (img) return { width: img.naturalWidth, height: img.naturalHeight } if (img) return { width: img.naturalWidth, height: img.naturalHeight };
// just show *something*, i guess... // just show *something*, i guess...
console.warn(new Error('Missing viewport properties')) console.warn(new Error("Missing viewport properties"));
return { width: 1000, height: 2000 } return { width: 1000, height: 2000 };
} };
export class FixedLayout extends HTMLElement { export class FixedLayout extends HTMLElement {
static observedAttributes = ['zoom'] static observedAttributes = ["zoom", "panel-mode"];
#root = this.attachShadow({ mode: 'closed' }) #root = this.attachShadow({ mode: "closed" });
#observer = new ResizeObserver(() => this.#render()) #observer = new ResizeObserver(() => this.#render());
#spreads #spreads;
#index = -1 #index = -1;
defaultViewport defaultViewport;
spread spread;
#portrait = false #portrait = false;
#left #left;
#right #right;
#center #center;
#side #side;
#zoom #zoom;
constructor() { // Panel detection support
super() #panelDetector = null;
#currentPanels = [];
#panelOverlay = null;
#touchStartX = 0;
#touchStartY = 0;
#touchStartTime = 0;
#panelIndex = 0;
constructor() {
super();
const sheet = new CSSStyleSheet() const sheet = new CSSStyleSheet();
this.#root.adoptedStyleSheets = [sheet] this.#root.adoptedStyleSheets = [sheet];
sheet.replaceSync(`:host { sheet.replaceSync(`:host {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
overflow: auto; overflow: auto;
}`) }`);
this.#observer.observe(this) this.#observer.observe(this);
// Initialize panel detector
if (customElements.get("foliate-fxl") === this.constructor) {
import("./panel-detection/detector.js").then((m) => {
this.#panelDetector = new m.PanelDetector();
});
} }
attributeChangedCallback(name, _, value) { }
switch (name) { attributeChangedCallback(name, _, value) {
case 'zoom': switch (name) {
this.#zoom = value !== 'fit-width' && value !== 'fit-page' case "zoom":
? parseFloat(value) : value this.#zoom =
this.#render() value !== "fit-width" && value !== "fit-page"
break ? parseFloat(value)
: value;
this.#render();
break;
case "panel-mode":
if (value === null) {
this.#exitPanelMode();
} }
break;
} }
async #createFrame({ index, src: srcOption }) { }
const srcOptionIsString = typeof srcOption === 'string' async #createFrame({ index, src: srcOption }) {
const src = srcOptionIsString ? srcOption : srcOption?.src const srcOptionIsString = typeof srcOption === "string";
const onZoom = srcOptionIsString ? null : srcOption?.onZoom const src = srcOptionIsString ? srcOption : srcOption?.src;
const element = document.createElement('div') const onZoom = srcOptionIsString ? null : srcOption?.onZoom;
element.setAttribute('dir', 'ltr') const element = document.createElement("div");
const iframe = document.createElement('iframe') element.setAttribute("dir", "ltr");
element.append(iframe) const iframe = document.createElement("iframe");
Object.assign(iframe.style, { element.append(iframe);
border: '0', Object.assign(iframe.style, {
display: 'none', border: "0",
overflow: 'hidden', display: "none",
}) overflow: "hidden",
// `allow-scripts` is needed for events because of WebKit bug });
// https://bugs.webkit.org/show_bug.cgi?id=218086 // `allow-scripts` is needed for events because of WebKit bug
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts') // https://bugs.webkit.org/show_bug.cgi?id=218086
iframe.setAttribute('scrolling', 'no') iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
iframe.setAttribute('part', 'filter') iframe.setAttribute("scrolling", "no");
this.#root.append(element) iframe.setAttribute("part", "filter");
if (!src) return { blank: true, element, iframe } this.#root.append(element);
return new Promise(resolve => { if (!src) return { blank: true, element, iframe };
iframe.addEventListener('load', () => { return new Promise((resolve) => {
const doc = iframe.contentDocument iframe.addEventListener(
this.dispatchEvent(new CustomEvent('load', { detail: { doc, index } })) "load",
const { width, height } = getViewport(doc, this.defaultViewport) () => {
resolve({ const doc = iframe.contentDocument;
element, iframe, this.dispatchEvent(
width: parseFloat(width), new CustomEvent("load", { detail: { doc, index } }),
height: parseFloat(height), );
onZoom, const { width, height } = getViewport(doc, this.defaultViewport);
}) resolve({
}, { once: true }) element,
iframe.src = src iframe,
}) width: parseFloat(width),
} height: parseFloat(height),
#render(side = this.#side) { onZoom,
if (!side) return });
const left = this.#left ?? {} },
const right = this.#center ?? this.#right ?? {} { once: true },
const target = side === 'left' ? left : right );
const { width, height } = this.getBoundingClientRect() iframe.src = src;
const portrait = this.spread !== 'both' && this.spread !== 'portrait' });
&& height > width }
this.#portrait = portrait #render(side = this.#side) {
const blankWidth = left.width ?? right.width ?? 0 if (!side) return;
const blankHeight = left.height ?? right.height ?? 0 const left = this.#left ?? {};
const right = this.#center ?? this.#right ?? {};
const target = side === "left" ? left : right;
const { width, height } = this.getBoundingClientRect();
const portrait =
this.spread !== "both" && this.spread !== "portrait" && height > width;
this.#portrait = portrait;
const blankWidth = left.width ?? right.width ?? 0;
const blankHeight = left.height ?? right.height ?? 0;
const scale = typeof this.#zoom === 'number' && !isNaN(this.#zoom) const scale =
? this.#zoom typeof this.#zoom === "number" && !isNaN(this.#zoom)
: (this.#zoom === 'fit-width' ? this.#zoom
? (portrait || this.#center : (this.#zoom === "fit-width"
? width / (target.width ?? blankWidth) ? portrait || this.#center
: width / ((left.width ?? blankWidth) + (right.width ?? blankWidth))) ? width / (target.width ?? blankWidth)
: (portrait || this.#center : width /
? Math.min( ((left.width ?? blankWidth) + (right.width ?? blankWidth))
width / (target.width ?? blankWidth), : portrait || this.#center
height / (target.height ?? blankHeight)) ? Math.min(
: Math.min( width / (target.width ?? blankWidth),
width / ((left.width ?? blankWidth) + (right.width ?? blankWidth)), height / (target.height ?? blankHeight),
height / Math.max( )
left.height ?? blankHeight, : Math.min(
right.height ?? blankHeight))) width /
) || 1 ((left.width ?? blankWidth) + (right.width ?? blankWidth)),
height /
Math.max(
left.height ?? blankHeight,
right.height ?? blankHeight,
),
)) || 1;
const transform = frame => { const transform = (frame) => {
let { element, iframe, width, height, blank, onZoom } = frame let { element, iframe, width, height, blank, onZoom } = frame;
if (!iframe) return if (!iframe) return;
if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale }) if (onZoom) onZoom({ doc: frame.iframe.contentDocument, scale });
const iframeScale = onZoom ? scale : 1 const iframeScale = onZoom ? scale : 1;
Object.assign(iframe.style, { Object.assign(iframe.style, {
width: `${width * iframeScale}px`, width: `${width * iframeScale}px`,
height: `${height * iframeScale}px`, height: `${height * iframeScale}px`,
transform: onZoom ? 'none' : `scale(${scale})`, transform: onZoom ? "none" : `scale(${scale})`,
transformOrigin: 'top left', transformOrigin: "top left",
display: blank ? 'none' : 'block', display: blank ? "none" : "block",
}) });
Object.assign(element.style, { Object.assign(element.style, {
width: `${(width ?? blankWidth) * scale}px`, width: `${(width ?? blankWidth) * scale}px`,
height: `${(height ?? blankHeight) * scale}px`, height: `${(height ?? blankHeight) * scale}px`,
overflow: 'hidden', overflow: "hidden",
display: 'block', display: "block",
flexShrink: '0', flexShrink: "0",
marginBlock: 'auto', marginBlock: "auto",
}) });
if (portrait && frame !== target) { if (portrait && frame !== target) {
element.style.display = 'none' element.style.display = "none";
} }
} };
if (this.#center) { if (this.#center) {
transform(this.#center) transform(this.#center);
} else { } else {
transform(left) transform(left);
transform(right) transform(right);
}
} }
async #showSpread({ left, right, center, side }) { }
this.#root.replaceChildren() async #showSpread({ left, right, center, side }) {
this.#left = null this.#root.replaceChildren();
this.#right = null this.#left = null;
this.#center = null this.#right = null;
if (center) { this.#center = null;
this.#center = await this.#createFrame(center) if (center) {
this.#side = 'center' this.#center = await this.#createFrame(center);
this.#render() this.#side = "center";
} else { this.#render();
this.#left = await this.#createFrame(left) } else {
this.#right = await this.#createFrame(right) this.#left = await this.#createFrame(left);
this.#side = this.#left.blank ? 'right' this.#right = await this.#createFrame(right);
: this.#right.blank ? 'left' : side this.#side = this.#left.blank
this.#render() ? "right"
} : this.#right.blank
? "left"
: side;
this.#render();
} }
#goLeft() { }
if (this.#center || this.#left?.blank) return #goLeft() {
if (this.#portrait && this.#left?.element?.style?.display === 'none') { if (this.#center || this.#left?.blank) return;
this.#side = 'left' if (this.#portrait && this.#left?.element?.style?.display === "none") {
this.#render() this.#side = "left";
this.#reportLocation('page') this.#render();
return true this.#reportLocation("page");
} return true;
} }
#goRight() { }
if (this.#center || this.#right?.blank) return #goRight() {
if (this.#portrait && this.#right?.element?.style?.display === 'none') { if (this.#center || this.#right?.blank) return;
this.#side = 'right' if (this.#portrait && this.#right?.element?.style?.display === "none") {
this.#render() this.#side = "right";
this.#reportLocation('page') this.#render();
return true this.#reportLocation("page");
} return true;
} }
open(book) { }
this.book = book open(book) {
const { rendition } = book this.book = book;
this.spread = rendition?.spread const { rendition } = book;
this.defaultViewport = rendition?.viewport this.spread = rendition?.spread;
this.defaultViewport = rendition?.viewport;
const rtl = book.dir === 'rtl' const rtl = book.dir === "rtl";
const ltr = !rtl const ltr = !rtl;
this.rtl = rtl this.rtl = rtl;
if (rendition?.spread === 'none') if (rendition?.spread === "none")
this.#spreads = book.sections.map(section => ({ center: section })) this.#spreads = book.sections.map((section) => ({ center: section }));
else this.#spreads = book.sections.reduce((arr, section, i) => { else
const last = arr[arr.length - 1] this.#spreads = book.sections.reduce(
const { pageSpread } = section (arr, section, i) => {
const newSpread = () => { const last = arr[arr.length - 1];
const spread = {} const { pageSpread } = section;
arr.push(spread) const newSpread = () => {
return spread const spread = {};
} arr.push(spread);
if (pageSpread === 'center') { return spread;
const spread = last.left || last.right ? newSpread() : last };
spread.center = section if (pageSpread === "center") {
} const spread = last.left || last.right ? newSpread() : last;
else if (pageSpread === 'left') { spread.center = section;
const spread = last.center || last.left || ltr && i ? newSpread() : last } else if (pageSpread === "left") {
spread.left = section const spread =
} last.center || last.left || (ltr && i) ? newSpread() : last;
else if (pageSpread === 'right') { spread.left = section;
const spread = last.center || last.right || rtl && i ? newSpread() : last } else if (pageSpread === "right") {
spread.right = section const spread =
} last.center || last.right || (rtl && i) ? newSpread() : last;
else if (ltr) { spread.right = section;
if (last.center || last.right) newSpread().left = section } else if (ltr) {
else if (last.left || !i) last.right = section if (last.center || last.right) newSpread().left = section;
else last.left = section else if (last.left || !i) last.right = section;
} else last.left = section;
else { } else {
if (last.center || last.left) newSpread().right = section if (last.center || last.left) newSpread().right = section;
else if (last.right || !i) last.left = section else if (last.right || !i) last.left = section;
else last.right = section else last.right = section;
} }
return arr return arr;
}, [{}]) },
[{}],
);
// Add touch support for panel navigation
this.#addTouchSupport();
}
#addTouchSupport() {
const opts = { passive: false };
this.addEventListener("touchstart", this.#onTouchStart.bind(this), opts);
this.addEventListener("touchmove", this.#onTouchMove.bind(this), opts);
this.addEventListener("touchend", this.#onTouchEnd.bind(this));
}
#onTouchStart(e) {
this.#touchStartTime = e.timeStamp;
const touch = e.changedTouches[0];
this.#touchStartX = touch?.screenX || 0;
this.#touchStartY = touch?.screenY || 0;
}
#onTouchMove(e) {
if (this.hasAttribute("panel-mode")) return;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = this.#touchStartX - touch.screenX;
const dy = this.#touchStartY - touch.screenY;
if (Math.abs(dx) > 10 || Math.abs(dy) > 10) {
e.preventDefault();
} }
get index() { }
const spread = this.#spreads[this.#index]
const section = spread?.center ?? (this.#side === 'left' async #onTouchEnd(e) {
? spread.left ?? spread.right : spread.right ?? spread.left) if (this.hasAttribute("panel-mode")) {
return this.book.sections.indexOf(section) await this.#handlePanelTouch(e);
return;
} }
#reportLocation(reason) {
this.dispatchEvent(new CustomEvent('relocate', { detail: const touch = e.changedTouches[0];
{ reason, range: null, index: this.index, fraction: 0, size: 1 } })) if (!touch) return;
const dx = this.#touchStartX - touch.screenX;
const dy = this.#touchStartY - touch.screenY;
const dt = e.timeStamp - (this.#touchStartTime || e.timeStamp);
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) return;
const vx = Math.abs(dx / dt);
if (Math.abs(dx) > Math.abs(dy) && vx > 0.3) {
if (dx > 0) await this.next();
else await this.prev();
} }
getSpreadOf(section) { }
const spreads = this.#spreads
for (let index = 0; index < spreads.length; index++) { async #handlePanelTouch(e) {
const { left, right, center } = spreads[index] const touch = e.changedTouches[0];
if (left === section) return { index, side: 'left' } if (!touch) return;
if (right === section) return { index, side: 'right' }
if (center === section) return { index, side: 'center' } const dx = this.#touchStartX - touch.screenX;
} const dy = this.#touchStartY - touch.screenY;
if (Math.abs(dx) < 10 && Math.abs(dy) < 10) {
// Tap - toggle panel overlay
this.#togglePanelOverlay();
return;
} }
async goToSpread(index, side, reason) {
if (index < 0 || index > this.#spreads.length - 1) return if (Math.abs(dx) > Math.abs(dy)) {
if (index === this.#index) { if (dx > 0) await this.nextPanel();
this.#render(side) else await this.prevPanel();
return } else {
} if (dy > 0) await this.nextPanel();
this.#index = index else await this.prevPanel();
const spread = this.#spreads[index]
if (spread.center) {
const index = this.book.sections.indexOf(spread.center)
const src = await spread.center?.load?.()
await this.#showSpread({ center: { index, src } })
} else {
const indexL = this.book.sections.indexOf(spread.left)
const indexR = this.book.sections.indexOf(spread.right)
const srcL = await spread.left?.load?.()
const srcR = await spread.right?.load?.()
const left = { index: indexL, src: srcL }
const right = { index: indexR, src: srcR }
await this.#showSpread({ left, right, side })
}
this.#reportLocation(reason)
} }
async select(target) { }
await this.goTo(target) get index() {
// TODO const spread = this.#spreads[this.#index];
const section =
spread?.center ??
(this.#side === "left"
? (spread.left ?? spread.right)
: (spread.right ?? spread.left));
return this.book.sections.indexOf(section);
}
#reportLocation(reason) {
this.dispatchEvent(
new CustomEvent("relocate", {
detail: {
reason,
range: null,
index: this.index,
fraction: 0,
size: 1,
},
}),
);
}
getSpreadOf(section) {
const spreads = this.#spreads;
for (let index = 0; index < spreads.length; index++) {
const { left, right, center } = spreads[index];
if (left === section) return { index, side: "left" };
if (right === section) return { index, side: "right" };
if (center === section) return { index, side: "center" };
} }
async goTo(target) { }
const { book } = this async goToSpread(index, side, reason) {
const resolved = await target if (index < 0 || index > this.#spreads.length - 1) return;
const section = book.sections[resolved.index] if (index === this.#index) {
if (!section) return this.#render(side);
const { index, side } = this.getSpreadOf(section) return;
await this.goToSpread(index, side)
} }
async next() { this.#index = index;
const s = this.rtl ? this.#goLeft() : this.#goRight() const spread = this.#spreads[index];
if (!s) return this.goToSpread(this.#index + 1, this.rtl ? 'right' : 'left', 'page') if (spread.center) {
const index = this.book.sections.indexOf(spread.center);
const src = await spread.center?.load?.();
await this.#showSpread({ center: { index, src } });
} else {
const indexL = this.book.sections.indexOf(spread.left);
const indexR = this.book.sections.indexOf(spread.right);
const srcL = await spread.left?.load?.();
const srcR = await spread.right?.load?.();
const left = { index: indexL, src: srcL };
const right = { index: indexR, src: srcR };
await this.#showSpread({ left, right, side });
} }
async prev() { this.#reportLocation(reason);
const s = this.rtl ? this.#goRight() : this.#goLeft() }
if (!s) return this.goToSpread(this.#index - 1, this.rtl ? 'left' : 'right', 'page') async select(target) {
await this.goTo(target);
// TODO
}
async goTo(target) {
const { book } = this;
const resolved = await target;
const section = book.sections[resolved.index];
if (!section) return;
const { index, side } = this.getSpreadOf(section);
await this.goToSpread(index, side);
}
async next() {
if (this.hasAttribute("panel-mode")) {
await this.nextPanel();
return;
} }
getContents() { const s = this.rtl ? this.#goLeft() : this.#goRight();
return Array.from(this.#root.querySelectorAll('iframe'), frame => ({ if (!s)
doc: frame.contentDocument, return this.goToSpread(
// TODO: index, overlayer this.#index + 1,
})) this.rtl ? "right" : "left",
"page",
);
}
async prev() {
if (this.hasAttribute("panel-mode")) {
await this.prevPanel();
return;
} }
destroy() { const s = this.rtl ? this.#goRight() : this.#goLeft();
this.#observer.unobserve(this) if (!s)
return this.goToSpread(
this.#index - 1,
this.rtl ? "left" : "right",
"page",
);
}
// Internal methods for panel navigation (bypass panel-mode check)
async #nextPage() {
const s = this.rtl ? this.#goLeft() : this.#goRight();
if (!s)
return this.goToSpread(
this.#index + 1,
this.rtl ? "right" : "left",
"page",
);
}
async #prevPage() {
const s = this.rtl ? this.#goRight() : this.#goLeft();
if (!s)
return this.goToSpread(
this.#index - 1,
this.rtl ? "left" : "right",
"page",
);
}
// Panel navigation methods
async nextPanel() {
if (!this.hasAttribute("panel-mode")) {
this.setAttribute("panel-mode", "");
await this.#enterPanelMode();
} }
if (this.#panelIndex < this.#currentPanels.length - 1) {
this.#panelIndex++;
await this.#zoomToPanel(this.#currentPanels[this.#panelIndex]);
} else {
await this.#nextPage();
this.#panelIndex = 0;
if (this.#currentPanels.length > 0) {
await this.#zoomToPanel(this.#currentPanels[0]);
}
}
}
async prevPanel() {
if (!this.hasAttribute("panel-mode")) return;
if (this.#panelIndex > 0) {
this.#panelIndex--;
await this.#zoomToPanel(this.#currentPanels[this.#panelIndex]);
} else {
await this.#prevPage();
this.#panelIndex = this.#currentPanels.length - 1;
if (this.#panelIndex >= 0) {
await this.#zoomToPanel(this.#currentPanels[this.#panelIndex]);
}
}
}
async #enterPanelMode() {
if (!this.#panelDetector) return;
const contents = this.getContents();
for (const { doc } of contents) {
if (doc) {
const index = this.index;
const result = await this.#panelDetector.detectPanels(doc, index);
this.#currentPanels = result.panels;
this.#panelIndex = 0;
break;
}
}
if (this.#currentPanels.length > 0) {
this.#showPanelOverlay();
await this.#zoomToPanel(this.#currentPanels[0]);
}
}
#exitPanelMode() {
this.#panelIndex = 0;
this.#currentPanels = [];
this.#hidePanelOverlay();
this.removeAttribute("panel-mode");
this.#render();
}
#togglePanelOverlay() {
if (this.#panelOverlay) {
this.#hidePanelOverlay();
} else {
this.#showPanelOverlay();
}
}
#showPanelOverlay() {
if (this.#panelOverlay || this.#currentPanels.length === 0) return;
const frame = this.#center || this.#left || this.#right;
if (!frame?.element) return;
this.#panelOverlay = document.createElement("div");
this.#panelOverlay.className = "panel-overlay";
this.#panelOverlay.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
`;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 100 100");
svg.style.cssText = "width: 100%; height: 100%;";
this.#currentPanels.forEach((panel, i) => {
const rect = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
rect.setAttribute("x", panel.x);
rect.setAttribute("y", panel.y);
rect.setAttribute("width", panel.width);
rect.setAttribute("height", panel.height);
rect.setAttribute("fill", "none");
rect.setAttribute(
"stroke",
i === this.#panelIndex ? "#ff6b35" : "rgba(255,255,255,0.5)",
);
rect.setAttribute("stroke-width", "2");
rect.setAttribute("stroke-dasharray", "5,5");
svg.appendChild(rect);
});
this.#panelOverlay.appendChild(svg);
frame.element.appendChild(this.#panelOverlay);
}
#hidePanelOverlay() {
if (this.#panelOverlay) {
this.#panelOverlay.remove();
this.#panelOverlay = null;
}
}
async #zoomToPanel(panel) {
const frame = this.#center || this.#left || this.#right;
if (!frame?.width || !frame?.height) return;
const containerRect = this.getBoundingClientRect();
const containerWidth = containerRect.width;
const containerHeight = containerRect.height;
const panelWidthPx = (panel.width / 100) * frame.width;
const panelHeightPx = (panel.height / 100) * frame.height;
const panelXPx = (panel.x / 100) * frame.width;
const panelYPx = (panel.y / 100) * frame.height;
const scaleX = containerWidth / panelWidthPx;
const scaleY = containerHeight / panelHeightPx;
const scale = Math.min(scaleX, scaleY) * 0.9;
const scrollX = (panelXPx + panelWidthPx / 2) * scale - containerWidth / 2;
const scrollY =
(panelYPx + panelHeightPx / 2) * scale - containerHeight / 2;
this.setAttribute("zoom", scale);
this.scrollTo(scrollX, scrollY);
this.#showPanelOverlay();
}
getContents() {
return Array.from(this.#root.querySelectorAll("iframe"), (frame) => ({
doc: frame.contentDocument,
// TODO: index, overlayer
}));
}
get panelCount() {
return this.#currentPanels.length;
}
get currentPanelIndex() {
return this.#panelIndex;
}
togglePanelMode() {
if (this.hasAttribute("panel-mode")) {
this.#exitPanelMode();
} else {
this.setAttribute("panel-mode", "");
this.#enterPanelMode();
}
}
destroy() {
this.#observer.unobserve(this);
}
} }
customElements.define('foliate-fxl', FixedLayout) customElements.define("foliate-fxl", FixedLayout);