Files
FrozenFriendsYume/packages/client/src/components/MkEpubViewer.vue
T
2026-07-26 12:29:43 +09:00

331 lines
7.1 KiB
Vue

<template>
<MkModalWindow
ref="windowEl"
:width="980"
:height="760"
:scroll="false"
@close="close"
@closed="$emit('closed')"
>
<template #header>
<span class="title">{{ file.name }}</span>
</template>
<div class="epubViewer">
<div class="toolbar">
<button class="_button control" type="button" @click="go('prev')">
<i class="ph-caret-left ph-bold ph-lg"></i>
</button>
<div class="status">
<span>{{ status }}</span>
<progress v-if="progress != null" :value="progress" max="100"></progress>
</div>
<button class="_button control" type="button" @click="go('next')">
<i class="ph-caret-right ph-bold ph-lg"></i>
</button>
<a
v-if="file.allowDownload"
class="_button control"
:href="file.url"
:download="file.name"
target="_blank"
rel="noopener"
>
<i class="ph-download-simple ph-bold ph-lg"></i>
</a>
<a
class="_button control"
:href="file.url"
target="_blank"
rel="noopener"
>
<i class="ph-arrow-square-out ph-bold ph-lg"></i>
</a>
</div>
<iframe
ref="frameEl"
class="frame"
:title="file.name"
:sandbox="sandbox"
:srcdoc="frameHtml"
></iframe>
</div>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { computed, onBeforeUnmount, ref, shallowRef } from "vue";
import type * as misskey from "iceshrimp-sdk";
import MkModalWindow from "@/components/MkModalWindow.vue";
const EPUBJS_CDN =
"https://cdn.jsdelivr.net/npm/epubjs@0.3.93/dist/epub.min.js";
const props = defineProps<{
file: misskey.entities.DriveFile;
}>();
defineEmits<{
(event: "closed"): void;
}>();
const windowEl = shallowRef<InstanceType<typeof MkModalWindow>>();
const frameEl = ref<HTMLIFrameElement | null>(null);
const status = ref("読み込み中");
const progress = ref<number | null>(null);
const loading = ref(false);
const controller = new AbortController();
const sandbox = [
"allow-scripts",
"allow-forms",
"allow-popups",
"allow-modals",
"allow-downloads",
].join(" ");
const frameHtml = computed(() => `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
html, body, #viewer {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #fff;
color: #111;
}
#viewer {
box-sizing: border-box;
padding: 0 12px;
}
#message {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
font: 14px/1.6 system-ui, sans-serif;
color: #555;
background: #fff;
z-index: 1;
}
#message[hidden] {
display: none;
}
</style>
</head>
<body>
<div id="message">EPUBビューアを読み込み中</div>
<div id="viewer"></div>
${"<scr" + "ipt"} src="${EPUBJS_CDN}">${"</scr" + "ipt"}
${"<scr" + "ipt"}
(() => {
const message = document.getElementById("message");
const viewer = document.getElementById("viewer");
let rendition;
let book;
function post(type, payload = {}) {
parent.postMessage({ source: "mk-epub-viewer", type, ...payload }, "*");
}
function show(text) {
message.hidden = false;
message.textContent = text;
}
function hide() {
message.hidden = true;
}
function reportProgress(location) {
const cfi = location?.start?.cfi;
if (!book?.locations || !cfi) return;
const percentage = book.locations.percentageFromCfi(cfi);
if (Number.isFinite(percentage)) {
post("progress", { progress: Math.round(percentage * 1000) / 10 });
}
}
async function load(buffer) {
try {
if (!window.ePub) throw new Error("epub.js の読み込みに失敗しました");
show("EPUBを開いています");
book = window.ePub();
await book.open(buffer, "binary");
rendition = book.renderTo(viewer, {
width: "100%",
height: "100%",
spread: "auto",
flow: "paginated",
allowScriptedContent: true,
});
rendition.on("relocated", reportProgress);
await rendition.display();
hide();
post("loaded");
book.ready
.then(() => book.locations.generate(1200))
.then(() => reportProgress(rendition.currentLocation()))
.catch(() => {});
} catch (err) {
show(err?.message || String(err));
post("error", { message: err?.message || String(err) });
}
}
window.addEventListener("message", (event) => {
const data = event.data || {};
if (data.source !== "mk-epub-viewer-host") return;
if (data.type === "load") load(data.buffer);
if (data.type === "next") rendition?.next();
if (data.type === "prev") rendition?.prev();
});
if (document.readyState === "complete") {
post("ready");
} else {
window.addEventListener("load", () => post("ready"), { once: true });
}
})();
${"</scr" + "ipt"}
</body>
</html>`);
async function loadBook() {
if (loading.value) return;
const frame = frameEl.value?.contentWindow;
if (!frame) return;
loading.value = true;
status.value = "EPUBを取得中";
try {
const res = await fetch(props.file.url, {
credentials: "omit",
cache: "default",
signal: controller.signal,
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const buffer = await res.arrayBuffer();
status.value = "EPUBを開いています";
frame.postMessage(
{
source: "mk-epub-viewer-host",
type: "load",
buffer,
},
"*",
[buffer],
);
} catch (err) {
if (controller.signal.aborted) return;
status.value = `読み込みに失敗しました: ${
err instanceof Error ? err.message : String(err)
}`;
} finally {
loading.value = false;
}
}
function onMessage(event: MessageEvent) {
const data = event.data || {};
if (data.source !== "mk-epub-viewer") return;
if (data.type === "ready") loadBook();
if (data.type === "loaded") status.value = "EPUB";
if (data.type === "progress") {
progress.value = data.progress;
status.value = `EPUB ${data.progress}%`;
}
if (data.type === "error") status.value = `表示に失敗しました: ${data.message}`;
}
function go(direction: "prev" | "next") {
frameEl.value?.contentWindow?.postMessage(
{
source: "mk-epub-viewer-host",
type: direction,
},
"*",
);
}
function close() {
windowEl.value?.close();
}
window.addEventListener("message", onMessage);
onBeforeUnmount(() => {
controller.abort();
window.removeEventListener("message", onMessage);
});
</script>
<style lang="scss" scoped>
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.epubViewer {
display: flex;
flex-direction: column;
height: 100%;
background: var(--panel);
}
.toolbar {
display: grid;
grid-template-columns: 38px minmax(0, 1fr) 38px 38px 38px;
gap: 8px;
align-items: center;
padding: 8px;
border-bottom: 1px solid var(--divider);
}
.control {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border-radius: 6px;
background: var(--buttonBg);
color: var(--fg);
&:hover {
background: var(--buttonHoverBg);
}
}
.status {
display: grid;
gap: 4px;
min-width: 0;
font-size: 0.9em;
> span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
> progress {
width: 100%;
height: 5px;
accent-color: var(--accent);
}
}
.frame {
display: block;
flex: 1;
width: 100%;
min-height: 0;
border: 0;
background: #fff;
}
</style>