mirror of
https://github.com/john-okeefe/foliate-js.git
synced 2026-09-09 11:29:14 -04:00
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:
+607
-278
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user