Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/common.api/src/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ enum FilesQueryKey {
}

const filesApiConfig = {
[FilesQueryKey.UploadImage]: {
[FilesQueryKey.UploadAttachment]: {
getUrl: () =>
`${env.VITE_SERVER_URL_BACKEND}/api/protected/storage-service/v2/file-kinds/uncategorized/files/`,
method: HttpMethod.POST,
},
[FilesQueryKey.UploadAttachment]: {
[FilesQueryKey.UploadImage]: {
getUrl: () =>
`${env.VITE_SERVER_URL_BACKEND}/api/protected/storage-service/v2/file-kinds/image/files/`,
method: HttpMethod.POST,
Expand Down
2 changes: 1 addition & 1 deletion packages/common.services/src/files/useUploadFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type UploadFileVars = { file: File; token?: string };

export async function uploadFileRequest({ file, token }: UploadFileVars): Promise<string> {
const axiosInst = await getAxiosInstance();
const { getUrl, method } = filesApiConfig[FilesQueryKey.UploadImage];
const { getUrl, method } = filesApiConfig[FilesQueryKey.UploadAttachment];
const formData = new FormData();
formData.append('upload', file);

Expand Down
2 changes: 1 addition & 1 deletion packages/common.services/src/files/useUploadImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type UploadImageVars = { file: File; token?: string };

export async function uploadImageRequest({ file, token }: UploadImageVars): Promise<string> {
const axiosInst = await getAxiosInstance();
const { getUrl, method } = filesApiConfig[FilesQueryKey.UploadAttachment];
const { getUrl, method } = filesApiConfig[FilesQueryKey.UploadImage];
const formData = new FormData();
formData.append('upload', file);

Expand Down
1 change: 1 addition & 0 deletions packages/modules.board/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"common.types": "*",
"features.materials.edit": "*",
"features.materials.card": "*",
"pdfjs-dist": "^4.10.38",
"pica": "9.0.1",
"react-hook-form": "^7.55.0",
"tldraw": "^3.15.1",
Expand Down
45 changes: 3 additions & 42 deletions packages/modules.board/src/features/imageStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { uploadImageRequest, uploadFileRequest } from 'common.services';
import { getAxiosInstance } from 'common.config';
import { TLAsset } from 'tldraw';
import { toast } from 'sonner';
import { resolveAssetUrl } from '../utils/resolveAssetUrl';

export type TLAssetContextT = {
screenScale: number;
Expand Down Expand Up @@ -43,9 +43,6 @@ const ALLOWED_IMAGE_MIME_TYPES = new Set([
const MAX_IMAGE_SIZE_BYTES = 1 * 1024 * 1024; // 1 MiB
const MAX_IMAGE_SIDE = 4096; // макс. сторона в пикселях

// Кеш blob URL для уже загруженных изображений (по исходному src)
const blobUrlCache = new Map<string, string>();

/** Узнать размеры изображения (без тяжёлых операций) */
async function probeImage(file: File): Promise<{ w: number; h: number; objectUrl: string }> {
const objectUrl = URL.createObjectURL(file);
Expand Down Expand Up @@ -153,48 +150,12 @@ export const myAssetStore = (token: string) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async resolve(asset: TLAsset, _ctx: TLAssetContextT) {
const src = asset.props.src;

// Если нет src или токена — возвращаем как есть
if (!src || !token) {
return src;
}

// Пропускаем data: и blob: URL — они уже пригодны к отображению
if (src.startsWith('data:') || src.startsWith('blob:')) {
return src;
}

// Проверяем кеш
const cached = blobUrlCache.get(src);
if (cached) {
return cached;
}
if (!src) return src;

try {
// Загружаем изображение с заголовком токена через axios
const axiosInst = await getAxiosInstance();
const response = await axiosInst.get(src, {
responseType: 'blob',
headers: {
'x-storage-token': token,
},
});

if (response.status !== 200) {
return src;
}

// Создаем blob URL из загруженного изображения
const blob = response.data;
const blobUrl = URL.createObjectURL(blob);

// Сохраняем в кеш
blobUrlCache.set(src, blobUrl);

return blobUrl;
return await resolveAssetUrl(src, token);
} catch (error) {
console.error('[myAssetStore.resolve] Ошибка при загрузке изображения:', error);
// На любой ошибке возвращаем исходный src
return src;
}
},
Expand Down
1 change: 1 addition & 0 deletions packages/modules.board/src/features/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './imageStore';
export { insertPdf } from './pickAndInsertPdf';
99 changes: 99 additions & 0 deletions packages/modules.board/src/features/pickAndInsertPdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { nanoid } from 'nanoid';
import { Editor, TLShapeId } from 'tldraw';
import { toast } from 'sonner';
import { uploadFileRequest } from 'common.services';
import * as pdfjsLib from 'pdfjs-dist';
import type { PdfShape } from '../shapes/pdf';

const MAX_PDF_SIZE_BYTES = 5 * 1024 * 1024; // 5 MiB
const MAX_PDF_SHAPES = 50;
const DEFAULT_PDF_WIDTH = 400;

export async function insertPdf(editor: Editor, file: File, token: string) {
if (file.type !== 'application/pdf') {
toast.error('Неподдерживаемый формат', {
description: 'Выберите файл в формате PDF.',
duration: 5000,
});
return;
}

if (file.size > MAX_PDF_SIZE_BYTES) {
toast.error('Файл слишком большой', {
description: `Размер PDF не должен превышать 5 MiB (сейчас ${(file.size / 1024 / 1024).toFixed(2)} MiB).`,
duration: 5000,
});
return;
}

const existingPdfCount = editor.getCurrentPageShapes().filter((s) => s.type === 'pdf').length;

if (existingPdfCount >= MAX_PDF_SHAPES) {
toast.error('Лимит PDF-файлов', {
description: `На доске может быть не более ${MAX_PDF_SHAPES} PDF-объектов.`,
duration: 5000,
});
return;
}

const shapeId = `shape:${nanoid()}` as TLShapeId;

let totalPages = 1;
let pageWidth = DEFAULT_PDF_WIDTH;
let pageHeight = Math.round(DEFAULT_PDF_WIDTH * Math.SQRT2); // A4 fallback

const objectUrl = URL.createObjectURL(file);
try {
const pdfDoc = await pdfjsLib.getDocument(objectUrl).promise;
totalPages = pdfDoc.numPages;

const firstPage = await pdfDoc.getPage(1);
const vp = firstPage.getViewport({ scale: 1 });
pageHeight = Math.round((DEFAULT_PDF_WIDTH / vp.width) * vp.height);
pageWidth = DEFAULT_PDF_WIDTH;

pdfDoc.destroy();
} catch {
// fall back to default dimensions
} finally {
URL.revokeObjectURL(objectUrl);
}

const viewportCenter = editor.getViewportPageBounds().center;

editor.createShapes<PdfShape>([
{
id: shapeId,
type: 'pdf',
x: viewportCenter.x - pageWidth / 2,
y: viewportCenter.y - pageHeight / 2,
props: {
src: '',
fileName: file.name,
totalPages,
currentPage: 1,
w: pageWidth,
h: pageHeight,
studentCanFlip: true,
},
},
]);

// Upload in background
(async () => {
try {
const serverUrl = await uploadFileRequest({ file, token });

editor.updateShape<PdfShape>({
id: shapeId,
type: 'pdf',
props: { src: serverUrl },
});
} catch (err) {
console.error('[insertPdf] Upload failed:', err);
const msg = err instanceof Error ? err.message : 'Не удалось загрузить PDF';
toast.error('Ошибка загрузки PDF', { description: msg, duration: 5000 });
editor.deleteShapes([shapeId]);
}
})();
}
16 changes: 14 additions & 2 deletions packages/modules.board/src/hooks/useYjsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { YKeyValue } from 'y-utility/y-keyvalue';
import * as Y from 'yjs';
import { myAssetStore } from '../features/imageStore';
import { PdfShapeUtil } from '../shapes/pdf';
import { BOARD_SCHEMA_VERSION } from '../utils/yjsConstants';
import { generateUserColor } from '../utils/userColor';

Expand Down Expand Up @@ -73,6 +74,10 @@ export type ExtendedStoreStatus = {
getUserCamera: () => CameraState | undefined;
/** Сохранить камеру текущего пользователя в Yjs meta (синхронизируется через Hocuspocus) */
setUserCamera: (camera: CameraState) => void;
/** Y.Map для хранения текущей страницы PDF по ключу `${shapeId}:${userId}` */
pdfPagesMap: Y.Map<number>;
/** Токен для доступа к файлам */
token: string;
};

type PendingChanges = {
Expand Down Expand Up @@ -114,6 +119,8 @@ type SharedEntry = {
readonlyMap: Y.Map<boolean>;
/** Камеры по userId — каждый пользователь хранит свою последнюю позицию камеры (синхронизируется с сервером) */
userCamerasMap: Y.Map<CameraState>;
/** Текущие страницы PDF: ключ — `${shapeId}:${userId}`, значение — номер страницы */
pdfPagesMap: Y.Map<number>;
releaseTimer: number | null;
};

Expand Down Expand Up @@ -147,6 +154,7 @@ function getOrCreateShared(hostUrl: string, ydocId: string, storageToken: string

const readonlyMap = yDoc.getMap<boolean>('readonly');
const userCamerasMap = yDoc.getMap<CameraState>('userCameras');
const pdfPagesMap = yDoc.getMap<number>('pdfPages');

const provider = new HocuspocusProvider({
url: hostUrl,
Expand All @@ -166,6 +174,7 @@ function getOrCreateShared(hostUrl: string, ydocId: string, storageToken: string
meta,
readonlyMap,
userCamerasMap,
pdfPagesMap,
releaseTimer: null,
};

Expand Down Expand Up @@ -218,7 +227,7 @@ export function useYjsStore({
const assetStore = token ? myAssetStore(token) : undefined;

return createTLStore({
shapeUtils: [...defaultShapeUtils, ...shapeUtils],
shapeUtils: [...defaultShapeUtils, PdfShapeUtil, ...shapeUtils],
...(assetStore ? { assets: assetStore } : {}),
});
});
Expand Down Expand Up @@ -248,7 +257,7 @@ export function useYjsStore({
return getOrCreateShared(hostUrl, ydocId, storageToken);
}, [hostUrl, ydocId, storageToken]);

const { provider, yDoc, yStore, meta, readonlyMap, userCamerasMap } = sharedEntry;
const { provider, yDoc, yStore, meta, readonlyMap, userCamerasMap, pdfPagesMap } = sharedEntry;

useEffect(() => {
setStoreWithStatus((prev) => ({ ...prev, status: 'loading', store }));
Expand Down Expand Up @@ -742,5 +751,8 @@ export function useYjsStore({
myPresenceId,
getUserCamera,
setUserCamera,

pdfPagesMap,
token: token ?? '',
};
}
74 changes: 74 additions & 0 deletions packages/modules.board/src/shapes/pdf/PdfPageControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useCallback } from 'react';
import { Button } from '@xipkg/button';
import { ArrowLeft, ArrowRight } from '@xipkg/icons';

type PdfPageControlsProps = {
fileName: string;
currentPage: number;
totalPages: number;
disabled: boolean;
onPageChange: (page: number) => void;
};

export const PdfPageControls = ({
fileName,
currentPage,
totalPages,
disabled,
onPageChange,
}: PdfPageControlsProps) => {
const goPrev = useCallback(
(e: React.PointerEvent) => {
e.stopPropagation();
if (currentPage > 1) onPageChange(currentPage - 1);
},
[currentPage, onPageChange],
);

const goNext = useCallback(
(e: React.PointerEvent) => {
e.stopPropagation();
if (currentPage < totalPages) onPageChange(currentPage + 1);
},
[currentPage, totalPages, onPageChange],
);

return (
<div
className="bg-gray-0 border-gray-10 pointer-events-auto flex shrink-0 items-center gap-2 rounded-b-xl border-t px-3 py-1.5 select-none"
onPointerDown={(e) => e.stopPropagation()}
>
{fileName && (
<span className="text-gray-60 min-w-0 flex-1 overflow-hidden text-xs text-ellipsis whitespace-nowrap">
{fileName}
</span>
)}
{!fileName && <span className="flex-1" />}
{totalPages > 1 && (
<>
<Button
variant="none"
size="s"
className="hover:bg-brand-0 h-6 w-6 shrink-0 rounded-lg p-0 disabled:opacity-30 disabled:hover:bg-transparent"
disabled={disabled || currentPage <= 1}
onPointerDown={goPrev}
>
<ArrowLeft className="h-4 w-4" />
</Button>
<span className="text-gray-80 shrink-0 text-center text-xs tabular-nums">
{currentPage} / {totalPages}
</span>
<Button
variant="none"
size="s"
className="hover:bg-brand-0 h-6 w-6 shrink-0 rounded-lg p-0 disabled:opacity-30 disabled:hover:bg-transparent"
disabled={disabled || currentPage >= totalPages}
onPointerDown={goNext}
>
<ArrowRight className="h-4 w-4" />
</Button>
</>
)}
</div>
);
};
23 changes: 23 additions & 0 deletions packages/modules.board/src/shapes/pdf/PdfShape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { T, TLBaseShape } from 'tldraw';

export type PdfShapeProps = {
src: string;
fileName: string;
totalPages: number;
currentPage: number;
w: number;
h: number;
studentCanFlip: boolean;
};

export type PdfShape = TLBaseShape<'pdf', PdfShapeProps>;

export const pdfShapeProps = {
src: T.string,
fileName: T.string,
totalPages: T.number,
currentPage: T.number,
w: T.number,
h: T.number,
studentCanFlip: T.boolean,
};
Loading
Loading