Initial commit
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user