mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-10-16 15:06:04 +08:00
feat(chatui): add tabbed workspace and PDF previews
This commit is contained in:
@@ -35,6 +35,7 @@
|
||||
"markstream-vue": "1.0.5-beta.0",
|
||||
"mermaid": "^11.12.2",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pdfjs-dist": "6.3.289",
|
||||
"pinia": "2.1.6",
|
||||
"pinyin-pro": "^3.26.0",
|
||||
"qrcode": "^1.5.4",
|
||||
|
||||
Generated
+134
@@ -69,6 +69,9 @@ importers:
|
||||
monaco-editor:
|
||||
specifier: ^0.52.2
|
||||
version: 0.52.2
|
||||
pdfjs-dist:
|
||||
specifier: 6.3.289
|
||||
version: 6.3.289
|
||||
pinia:
|
||||
specifier: 2.1.6
|
||||
version: 2.1.6(typescript@5.1.6)(vue@3.3.4)
|
||||
@@ -518,6 +521,81 @@ packages:
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.9':
|
||||
resolution: {integrity: sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.9':
|
||||
resolution: {integrity: sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.9':
|
||||
resolution: {integrity: sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.9':
|
||||
resolution: {integrity: sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.9':
|
||||
resolution: {integrity: sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.9':
|
||||
resolution: {integrity: sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.9':
|
||||
resolution: {integrity: sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.9':
|
||||
resolution: {integrity: sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.9':
|
||||
resolution: {integrity: sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.9':
|
||||
resolution: {integrity: sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.9':
|
||||
resolution: {integrity: sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@1.0.9':
|
||||
resolution: {integrity: sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2451,6 +2529,10 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdfjs-dist@6.3.289:
|
||||
resolution: {integrity: sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==}
|
||||
engines: {node: '>=22.13.0 || >=24'}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
@@ -3518,6 +3600,54 @@ snapshots:
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
|
||||
'@napi-rs/canvas-android-arm64@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@1.0.9':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@1.0.9':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 1.0.9
|
||||
'@napi-rs/canvas-darwin-arm64': 1.0.9
|
||||
'@napi-rs/canvas-darwin-x64': 1.0.9
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.9
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 1.0.9
|
||||
'@napi-rs/canvas-linux-arm64-musl': 1.0.9
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.9
|
||||
'@napi-rs/canvas-linux-x64-gnu': 1.0.9
|
||||
'@napi-rs/canvas-linux-x64-musl': 1.0.9
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 1.0.9
|
||||
'@napi-rs/canvas-win32-x64-msvc': 1.0.9
|
||||
optional: true
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -5594,6 +5724,10 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdfjs-dist@6.3.289:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 1.0.9
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const packagePath = require.resolve("pdfjs-dist/package.json");
|
||||
const root = dirname(packagePath);
|
||||
const { version } = JSON.parse(readFileSync(packagePath, "utf8"));
|
||||
|
||||
/** Serve the same versioned PDF resources in development and production. */
|
||||
export function pdfjsAssets() {
|
||||
const files = new Map([["LICENSE", join(root, "LICENSE")]]);
|
||||
for (const directory of ["cmaps", "standard_fonts", "wasm", "iccs"]) {
|
||||
for (const entry of readdirSync(join(root, directory), {
|
||||
withFileTypes: true,
|
||||
})) {
|
||||
if (entry.isFile()) {
|
||||
files.set(
|
||||
`${directory}/${entry.name}`,
|
||||
join(root, directory, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: "pdfjs-assets",
|
||||
configureServer(server) {
|
||||
server.middlewares.use(
|
||||
`${server.config.base}pdfjs/${version}/`,
|
||||
(req, res, next) => {
|
||||
const name = req.url?.split("?")[0].replace(/^\//, "");
|
||||
const file = files.get(name);
|
||||
if (!file) return next();
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
name.endsWith(".wasm")
|
||||
? "application/wasm"
|
||||
: /\.m?js$/.test(name)
|
||||
? "text/javascript"
|
||||
: "application/octet-stream",
|
||||
);
|
||||
res.end(readFileSync(file));
|
||||
},
|
||||
);
|
||||
},
|
||||
generateBundle() {
|
||||
for (const [name, file] of files) {
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: `pdfjs/${version}/${name}`,
|
||||
source: readFileSync(file),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -944,11 +944,12 @@ export const chatApi = {
|
||||
}),
|
||||
);
|
||||
},
|
||||
downloadProjectWorkspaceFile(projectId: string, path: string) {
|
||||
downloadProjectWorkspaceFile(projectId: string, path: string, signal?: AbortSignal) {
|
||||
return openApiV1.downloadChatProjectWorkspaceFile({
|
||||
path: { project_id: projectId },
|
||||
query: { path },
|
||||
responseType: 'blob',
|
||||
signal,
|
||||
}) as Promise<AxiosResponse<Blob>>;
|
||||
},
|
||||
addProjectSession(projectId: string, sessionId: string) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<div
|
||||
class="pdf-page"
|
||||
:style="{ width: `${width}px`, height: `${height}px` }"
|
||||
role="img"
|
||||
:aria-label="tm('workspaceFiles.pdf.pageLabel', { page, total })"
|
||||
>
|
||||
<div v-if="error" class="pdf-placeholder pdf-error" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-else-if="!rendered" class="pdf-placeholder" aria-hidden="true">
|
||||
<v-progress-circular v-if="visible" indeterminate size="22" width="2" />
|
||||
<span>{{ page }}</span>
|
||||
</div>
|
||||
<div ref="pageHost" class="pdf-canvas" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import type { PDFDocumentProxy, RenderTask } from "pdfjs-dist";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
|
||||
const props = defineProps<{
|
||||
document: PDFDocumentProxy;
|
||||
page: number;
|
||||
total: number;
|
||||
width: number;
|
||||
height: number;
|
||||
scrollRoot: HTMLElement | null;
|
||||
}>();
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
const pageHost = ref<HTMLElement | null>(null);
|
||||
const visible = ref(false);
|
||||
const rendered = ref(false);
|
||||
const error = ref("");
|
||||
let observer: IntersectionObserver | undefined;
|
||||
onMounted(() => {
|
||||
observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
visible.value = entry.isIntersecting;
|
||||
},
|
||||
{
|
||||
root: props.scrollRoot,
|
||||
rootMargin: "600px 0px",
|
||||
},
|
||||
);
|
||||
if (pageHost.value) observer.observe(pageHost.value);
|
||||
});
|
||||
onBeforeUnmount(() => observer?.disconnect());
|
||||
|
||||
watch(
|
||||
[visible, () => props.document, () => props.width, () => props.page],
|
||||
async ([nearViewport, document, width, pageNumber], _, onCleanup) => {
|
||||
let cancelled = false;
|
||||
let task: RenderTask | undefined;
|
||||
let canvas: HTMLCanvasElement | undefined;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
task?.cancel();
|
||||
canvas?.remove();
|
||||
// Release the raster buffer when a page leaves the nearby viewport.
|
||||
if (canvas) canvas.width = canvas.height = 0;
|
||||
});
|
||||
rendered.value = false;
|
||||
error.value = "";
|
||||
if (!nearViewport || width <= 0) return;
|
||||
try {
|
||||
const page = await document.getPage(pageNumber);
|
||||
if (cancelled) return;
|
||||
const original = page.getViewport({ scale: 1 });
|
||||
const view = page.getViewport({ scale: width / original.width });
|
||||
const ratio = Math.min(
|
||||
window.devicePixelRatio || 1,
|
||||
2,
|
||||
Math.sqrt(8_000_000 / (view.width * view.height)),
|
||||
8192 / view.width,
|
||||
8192 / view.height,
|
||||
);
|
||||
canvas = window.document.createElement("canvas");
|
||||
canvas.width = Math.max(1, Math.floor(view.width * ratio));
|
||||
canvas.height = Math.max(1, Math.floor(view.height * ratio));
|
||||
canvas.style.width = `${view.width}px`;
|
||||
canvas.style.height = `${view.height}px`;
|
||||
canvas.setAttribute("aria-hidden", "true");
|
||||
task = page.render({
|
||||
canvas,
|
||||
viewport: view,
|
||||
transform: [ratio, 0, 0, ratio, 0, 0],
|
||||
});
|
||||
await task.promise;
|
||||
if (cancelled) return;
|
||||
pageHost.value?.replaceChildren(canvas);
|
||||
rendered.value = true;
|
||||
} catch (cause) {
|
||||
if (
|
||||
!cancelled &&
|
||||
(cause as Error)?.name !== "RenderingCancelledException"
|
||||
) {
|
||||
error.value = tm("workspaceFiles.pdf.loadFailed");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ flush: "post" },
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pdf-page {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
background: white;
|
||||
box-shadow: 0 1px 6px #0002;
|
||||
}
|
||||
.pdf-canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
.pdf-canvas :deep(canvas) {
|
||||
display: block;
|
||||
}
|
||||
.pdf-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
color: #888;
|
||||
font-size: 13px;
|
||||
}
|
||||
.pdf-error {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="workspace-pdf">
|
||||
<div
|
||||
ref="viewport"
|
||||
class="pdf-scroll"
|
||||
:aria-busy="loading"
|
||||
@scroll.passive="rememberPage"
|
||||
>
|
||||
<div v-if="error" class="pdf-state" role="alert">{{ error }}</div>
|
||||
<div v-else-if="loading" class="pdf-state" role="status">
|
||||
<v-progress-circular indeterminate size="24" width="2" />
|
||||
<span>{{ tm("workspaceFiles.pdf.loading") }}</span>
|
||||
</div>
|
||||
<div v-else-if="pdf" class="pdf-pages">
|
||||
<WorkspacePdfPage
|
||||
v-for="(layout, index) in pageLayouts"
|
||||
:key="index"
|
||||
:document="pdf"
|
||||
:page="index + 1"
|
||||
:total="pdf.numPages"
|
||||
:width="layout.width"
|
||||
:height="layout.height"
|
||||
:scroll-root="viewport"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="pdf && !loading && !error"
|
||||
class="pdf-zoom-hub"
|
||||
role="group"
|
||||
:aria-label="tm('workspaceFiles.pdf.zoomControls')"
|
||||
>
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="zoom <= 0.5"
|
||||
:aria-label="tm('workspaceFiles.pdf.zoomOut')"
|
||||
:title="tm('workspaceFiles.pdf.zoomOut')"
|
||||
@click="emit('update:zoom', Math.max(0.5, zoom - 0.25))"
|
||||
><Minus :size="16"
|
||||
/></v-btn>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
class="pdf-zoom-value"
|
||||
:title="tm('workspaceFiles.pdf.fitWidth')"
|
||||
:aria-label="tm('workspaceFiles.pdf.fitWidth')"
|
||||
@click="emit('update:zoom', 1)"
|
||||
>{{ Math.round(zoom * 100) }}%</v-btn
|
||||
>
|
||||
<v-btn
|
||||
icon
|
||||
size="x-small"
|
||||
variant="text"
|
||||
:disabled="zoom >= 3"
|
||||
:aria-label="tm('workspaceFiles.pdf.zoomIn')"
|
||||
:title="tm('workspaceFiles.pdf.zoomIn')"
|
||||
@click="emit('update:zoom', Math.min(3, zoom + 0.25))"
|
||||
><Plus :size="16"
|
||||
/></v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
} from "vue";
|
||||
import { Minus, Plus } from "@lucide/vue";
|
||||
import {
|
||||
getDocument,
|
||||
GlobalWorkerOptions,
|
||||
version,
|
||||
type PDFDocumentProxy,
|
||||
type PDFDocumentLoadingTask,
|
||||
} from "pdfjs-dist";
|
||||
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import WorkspacePdfPage from "./WorkspacePdfPage.vue";
|
||||
|
||||
const props = defineProps<{ file: Blob; page: number; zoom: number }>();
|
||||
const emit = defineEmits<{
|
||||
"update:page": [page: number];
|
||||
"update:zoom": [zoom: number];
|
||||
}>();
|
||||
const { tm } = useModuleI18n("features/chat");
|
||||
const pdf = shallowRef<PDFDocumentProxy | null>(null);
|
||||
const viewport = ref<HTMLElement | null>(null);
|
||||
const width = ref(0);
|
||||
const loading = ref(true);
|
||||
const error = ref("");
|
||||
const pageSizes = shallowRef<Array<{ width: number; height: number }>>([]);
|
||||
const pageLayouts = computed(() => {
|
||||
const pageWidth = Math.max(1, width.value - 32) * props.zoom;
|
||||
let top = 16;
|
||||
return pageSizes.value.map((size) => {
|
||||
const height = (pageWidth * size.height) / size.width;
|
||||
const layout = { width: pageWidth, height, top };
|
||||
top += height + 16;
|
||||
return layout;
|
||||
});
|
||||
});
|
||||
GlobalWorkerOptions.workerSrc = workerUrl;
|
||||
const resourceBase = `${import.meta.env.BASE_URL}pdfjs/${version}/`;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
width.value = Math.floor(entry.contentRect.width);
|
||||
});
|
||||
onMounted(() => {
|
||||
if (viewport.value) observer.observe(viewport.value);
|
||||
});
|
||||
onBeforeUnmount(() => observer.disconnect());
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
async (file, _, onCleanup) => {
|
||||
let cancelled = false;
|
||||
let task: PDFDocumentLoadingTask | undefined;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
void task?.destroy().catch(() => {});
|
||||
});
|
||||
pdf.value = null;
|
||||
pageSizes.value = [];
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const data = await file.arrayBuffer();
|
||||
if (cancelled) return;
|
||||
task = getDocument({
|
||||
data,
|
||||
cMapUrl: `${resourceBase}cmaps/`,
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: `${resourceBase}standard_fonts/`,
|
||||
wasmUrl: `${resourceBase}wasm/`,
|
||||
iccUrl: `${resourceBase}iccs/`,
|
||||
});
|
||||
const document = await task.promise;
|
||||
if (cancelled) return;
|
||||
// Read dimensions without rasterizing pages, keeping mixed-size page slots stable.
|
||||
const sizes: Array<{ width: number; height: number }> = [];
|
||||
for (let start = 1; start <= document.numPages; start += 20) {
|
||||
const batch = await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(20, document.numPages - start + 1) },
|
||||
async (_, index) => {
|
||||
const page = await document.getPage(start + index);
|
||||
const { width, height } = page.getViewport({ scale: 1 });
|
||||
return { width, height };
|
||||
},
|
||||
),
|
||||
);
|
||||
if (cancelled) return;
|
||||
sizes.push(...batch);
|
||||
}
|
||||
pdf.value = document;
|
||||
pageSizes.value = sizes;
|
||||
} catch (cause) {
|
||||
if (cancelled) return;
|
||||
error.value = tm(
|
||||
(cause as Error)?.name === "PasswordException"
|
||||
? "workspaceFiles.pdf.passwordProtected"
|
||||
: "workspaceFiles.pdf.loadFailed",
|
||||
);
|
||||
} finally {
|
||||
if (!cancelled) loading.value = false;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(pageLayouts, async (layouts, previous, onCleanup) => {
|
||||
let cancelled = false;
|
||||
onCleanup(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
const container = viewport.value;
|
||||
if (!container || !layouts.length) return;
|
||||
const scrollTop = container.scrollTop;
|
||||
const oldIndex = previous.findIndex(
|
||||
(layout) => layout.top + layout.height > scrollTop,
|
||||
);
|
||||
const index = Math.min(
|
||||
layouts.length - 1,
|
||||
oldIndex >= 0 ? oldIndex : Math.max(0, props.page - 1),
|
||||
);
|
||||
const offset =
|
||||
oldIndex >= 0
|
||||
? (scrollTop - previous[index].top) / previous[index].height
|
||||
: 0;
|
||||
await nextTick();
|
||||
if (cancelled) return;
|
||||
// Keep the same position within the page when zooming or resizing the panel.
|
||||
container.scrollTop = Math.max(
|
||||
0,
|
||||
layouts[index].top + offset * layouts[index].height,
|
||||
);
|
||||
});
|
||||
|
||||
function rememberPage() {
|
||||
if (loading.value || !viewport.value) return;
|
||||
const top = viewport.value.scrollTop + 16;
|
||||
const index = pageLayouts.value.findIndex(
|
||||
(layout) => layout.top + layout.height > top,
|
||||
);
|
||||
if (index >= 0 && index + 1 !== props.page) emit("update:page", index + 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-pdf {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.pdf-scroll {
|
||||
overflow: auto;
|
||||
overflow-anchor: none;
|
||||
scrollbar-gutter: stable;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
.pdf-pages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding: 16px 16px 80px;
|
||||
}
|
||||
.pdf-zoom-hub {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 16px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 12px;
|
||||
background: rgba(var(--v-theme-surface), 0.92);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
box-shadow: 0 4px 20px #0002;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.pdf-zoom-value {
|
||||
min-width: 50px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.pdf-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.65);
|
||||
}
|
||||
</style>
|
||||
@@ -173,9 +173,9 @@
|
||||
"confirmDelete": "Are you sure you want to delete project \"{title}\"? Conversations in this project will not be deleted."
|
||||
},
|
||||
"workspaceFiles": {
|
||||
"title": "Workspace Files",
|
||||
"open": "Open workspace files",
|
||||
"close": "Close workspace files",
|
||||
"title": "Files",
|
||||
"open": "Open files",
|
||||
"close": "Close files",
|
||||
"refresh": "Refresh file tree",
|
||||
"filter": "Filter files...",
|
||||
"clearFilter": "Clear file filter",
|
||||
@@ -188,7 +188,23 @@
|
||||
"downloadFailed": "Failed to download this file",
|
||||
"dialogPreview": "Open larger preview",
|
||||
"closePreview": "Close file preview",
|
||||
"closeDialogPreview": "Close larger preview"
|
||||
"closeDialogPreview": "Close larger preview",
|
||||
"pdf": {
|
||||
"zoomControls": "PDF zoom",
|
||||
"zoomOut": "Zoom out",
|
||||
"zoomIn": "Zoom in",
|
||||
"fitWidth": "Fit page width",
|
||||
"loading": "Loading PDF…",
|
||||
"pageLabel": "Page {page} of {total}",
|
||||
"passwordProtected": "This PDF is password protected. Download it to open it with a password.",
|
||||
"loadFailed": "Unable to preview this PDF. You can still download the file.",
|
||||
"tooLarge": "PDFs larger than 50 MB cannot be previewed. Download the file to view it."
|
||||
},
|
||||
"openTabs": "Open files",
|
||||
"closeTab": "Close {name}",
|
||||
"showTree": "Show file tree",
|
||||
"hideTree": "Hide file tree",
|
||||
"selectFile": "Select a file to preview"
|
||||
},
|
||||
"time": {
|
||||
"today": "Today",
|
||||
|
||||
@@ -173,9 +173,9 @@
|
||||
"confirmDelete": "プロジェクト「{title}」を削除しますか?プロジェクト内の会話は削除されません。"
|
||||
},
|
||||
"workspaceFiles": {
|
||||
"title": "ワークスペースのファイル",
|
||||
"open": "ワークスペースのファイルを開く",
|
||||
"close": "ワークスペースのファイルを閉じる",
|
||||
"title": "ファイル",
|
||||
"open": "ファイルを開く",
|
||||
"close": "ファイルパネルを閉じる",
|
||||
"refresh": "ファイルツリーを再読み込み",
|
||||
"filter": "ファイルを絞り込み…",
|
||||
"clearFilter": "ファイルの絞り込みを解除",
|
||||
@@ -188,7 +188,23 @@
|
||||
"downloadFailed": "ファイルのダウンロードに失敗しました",
|
||||
"dialogPreview": "拡大プレビューを開く",
|
||||
"closePreview": "ファイルプレビューを閉じる",
|
||||
"closeDialogPreview": "拡大プレビューを閉じる"
|
||||
"closeDialogPreview": "拡大プレビューを閉じる",
|
||||
"pdf": {
|
||||
"zoomControls": "PDF のズーム",
|
||||
"zoomOut": "縮小",
|
||||
"zoomIn": "拡大",
|
||||
"fitWidth": "ページ幅に合わせる",
|
||||
"loading": "PDF を読み込み中…",
|
||||
"pageLabel": "{total} ページ中 {page} ページ",
|
||||
"passwordProtected": "この PDF はパスワードで保護されています。ダウンロードしてパスワードを入力してください。",
|
||||
"loadFailed": "この PDF をプレビューできません。ファイルをダウンロードして確認してください。",
|
||||
"tooLarge": "50 MB を超える PDF はプレビューできません。ダウンロードして確認してください。"
|
||||
},
|
||||
"openTabs": "開いているファイル",
|
||||
"closeTab": "{name} を閉じる",
|
||||
"showTree": "ファイルツリーを表示",
|
||||
"hideTree": "ファイルツリーを隠す",
|
||||
"selectFile": "ファイルを選択してプレビュー"
|
||||
},
|
||||
"time": {
|
||||
"today": "今日",
|
||||
|
||||
@@ -173,9 +173,9 @@
|
||||
"confirmDelete": "Вы уверены, что хотите удалить проект «{title}»? Диалоги внутри проекта не будут удалены."
|
||||
},
|
||||
"workspaceFiles": {
|
||||
"title": "Файлы рабочей области",
|
||||
"open": "Открыть файлы рабочей области",
|
||||
"close": "Закрыть файлы рабочей области",
|
||||
"title": "Файлы",
|
||||
"open": "Открыть файлы",
|
||||
"close": "Закрыть панель файлов",
|
||||
"refresh": "Обновить дерево файлов",
|
||||
"filter": "Фильтр файлов...",
|
||||
"clearFilter": "Очистить фильтр файлов",
|
||||
@@ -188,7 +188,23 @@
|
||||
"downloadFailed": "Не удалось скачать файл",
|
||||
"dialogPreview": "Открыть увеличенный просмотр",
|
||||
"closePreview": "Закрыть предпросмотр файла",
|
||||
"closeDialogPreview": "Закрыть увеличенный просмотр"
|
||||
"closeDialogPreview": "Закрыть увеличенный просмотр",
|
||||
"pdf": {
|
||||
"zoomControls": "Масштаб PDF",
|
||||
"zoomOut": "Уменьшить",
|
||||
"zoomIn": "Увеличить",
|
||||
"fitWidth": "По ширине страницы",
|
||||
"loading": "Загрузка PDF…",
|
||||
"pageLabel": "Страница {page} из {total}",
|
||||
"passwordProtected": "Этот PDF защищён паролем. Скачайте файл и откройте его с паролем.",
|
||||
"loadFailed": "Не удалось открыть PDF для просмотра. Вы можете скачать файл.",
|
||||
"tooLarge": "Предпросмотр PDF размером более 50 МБ недоступен. Скачайте файл для просмотра."
|
||||
},
|
||||
"openTabs": "Открытые файлы",
|
||||
"closeTab": "Закрыть {name}",
|
||||
"showTree": "Показать дерево файлов",
|
||||
"hideTree": "Скрыть дерево файлов",
|
||||
"selectFile": "Выберите файл для просмотра"
|
||||
},
|
||||
"time": {
|
||||
"today": "Сегодня",
|
||||
|
||||
@@ -173,9 +173,9 @@
|
||||
"confirmDelete": "确定要删除项目 \"{title}\" 吗?项目中的对话不会被删除。"
|
||||
},
|
||||
"workspaceFiles": {
|
||||
"title": "工作区文件",
|
||||
"open": "打开工作区文件",
|
||||
"close": "关闭工作区文件",
|
||||
"title": "文件",
|
||||
"open": "打开文件",
|
||||
"close": "关闭文件面板",
|
||||
"refresh": "刷新文件树",
|
||||
"filter": "筛选文件...",
|
||||
"clearFilter": "清除文件筛选",
|
||||
@@ -188,7 +188,23 @@
|
||||
"downloadFailed": "下载文件失败",
|
||||
"dialogPreview": "放大预览",
|
||||
"closePreview": "关闭文件预览",
|
||||
"closeDialogPreview": "关闭放大预览"
|
||||
"closeDialogPreview": "关闭放大预览",
|
||||
"pdf": {
|
||||
"zoomControls": "PDF 缩放",
|
||||
"zoomOut": "缩小",
|
||||
"zoomIn": "放大",
|
||||
"fitWidth": "适应页面宽度",
|
||||
"loading": "正在加载 PDF…",
|
||||
"pageLabel": "第 {page} 页,共 {total} 页",
|
||||
"passwordProtected": "此 PDF 受密码保护,请下载后输入密码查看。",
|
||||
"loadFailed": "无法预览此 PDF,仍可下载文件查看。",
|
||||
"tooLarge": "超过 50 MB 的 PDF 暂不支持预览,请下载后查看。"
|
||||
},
|
||||
"openTabs": "已打开的文件",
|
||||
"closeTab": "关闭 {name}",
|
||||
"showTree": "展开文件树",
|
||||
"hideTree": "收起文件树",
|
||||
"selectFile": "选择文件开始预览"
|
||||
},
|
||||
"time": {
|
||||
"today": "今天",
|
||||
|
||||
@@ -6,6 +6,8 @@ import vuetify from 'vite-plugin-vuetify';
|
||||
import webfontDl from 'vite-plugin-webfont-dl';
|
||||
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
|
||||
import { runMdiSubset } from './scripts/subset-mdi-font.mjs';
|
||||
// @ts-ignore — .mjs not in TS project scope; Vite resolves this at runtime
|
||||
import { pdfjsAssets } from './scripts/pdfjs-assets.mjs';
|
||||
|
||||
const t2iShikiRuntimePath = fileURLToPath(
|
||||
new URL('../astrbot/core/utils/t2i/template/shiki_runtime.iife.js', import.meta.url)
|
||||
@@ -61,6 +63,7 @@ export default defineConfig(({ command }) => ({
|
||||
// Only run MDI subsetting during production builds, skip in dev server
|
||||
...(command === 'build' ? [mdiSubset()] : []),
|
||||
t2iShikiRuntimeAsset(),
|
||||
pdfjsAssets(),
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
|
||||
Reference in New Issue
Block a user