331 lines
7.1 KiB
Vue
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>
|