WIP: Add canvas zoom/pan/pinch event handlers - handlers deployed but zoom interaction not working yet, will debug later
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
{@render children()}
|
{@render children()}
|
||||||
|
|||||||
@@ -65,12 +65,14 @@
|
|||||||
|
|
||||||
const getSvgIcon = (iconType: string): string => {
|
const getSvgIcon = (iconType: string): string => {
|
||||||
const icons: {[key: string]: string} = {
|
const icons: {[key: string]: string} = {
|
||||||
pdf: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6M9 16h6"></path></svg>`,
|
pdf: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#D32F2F" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">PDF</text></svg>`,
|
||||||
doc: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>`,
|
doc: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#2B579A" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">W</text></svg>`,
|
||||||
sheet: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2-10H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V5a2 2 0 00-2-2z"></path></svg>`,
|
sheet: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#217346" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">X</text></svg>`,
|
||||||
presentation: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4v16m10 0V4m3-2H4a2 2 0 00-2 2v16a2 2 0 002 2h16a2 2 0 002-2V4a2 2 0 00-2-2z"></path></svg>`,
|
presentation: `<svg viewBox="0 0 24 24" fill="none" style="width:16px;height:16px;"><rect fill="#D35230" width="24" height="24" rx="2"/><text x="12" y="16" font-size="10" font-weight="bold" fill="white" text-anchor="middle">P</text></svg>`,
|
||||||
image: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`,
|
image: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;color:#6b7280;"><rect x="2" y="2" width="20" height="20" rx="2"/><circle cx="8" cy="8" r="2"/><path d="M21 15l-5-5L5 21"/></svg>`,
|
||||||
file: `<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" style="width:16px;height:16px;"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>`,
|
zip: `<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px;color:#6b7280;"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14zm-5-9h-3v4h3v-4zm0-3h-3v2h3V7z"/></svg>`,
|
||||||
|
text: `<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px;color:#6b7280;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/><path d="M16 13H8M16 17H8M10 9H8"/></svg>`,
|
||||||
|
file: `<svg viewBox="0 0 24 24" fill="currentColor" style="width:16px;height:16px;color:#6b7280;"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/></svg>`,
|
||||||
};
|
};
|
||||||
return icons[iconType] || icons.file;
|
return icons[iconType] || icons.file;
|
||||||
};
|
};
|
||||||
@@ -91,6 +93,399 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canPreviewInline = (name: string, contentType: string = ''): boolean => {
|
||||||
|
const ext = (name || '').toLowerCase().split('.').pop();
|
||||||
|
const type = (contentType || '').toLowerCase();
|
||||||
|
return ext === 'pdf' || type.includes('pdf') ||
|
||||||
|
['doc', 'docx'].includes(ext as string) || type.includes('word') ||
|
||||||
|
['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext as string) || type.includes('image');
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderPdfPage = async () => {
|
||||||
|
if (!currentPdf || !currentPdf.doc) return;
|
||||||
|
|
||||||
|
const pageObj = await currentPdf.doc.getPage(currentPdf.page);
|
||||||
|
const viewport = pageObj.getViewport({ scale: currentPdf.scale });
|
||||||
|
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return;
|
||||||
|
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
|
||||||
|
await pageObj.render({
|
||||||
|
canvasContext: context,
|
||||||
|
viewport: viewport,
|
||||||
|
}).promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderImageOnCanvas = () => {
|
||||||
|
if (!currentPdf?.imageElement) return;
|
||||||
|
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const scale = currentPdf.scale;
|
||||||
|
canvas.width = (currentPdf.imageWidth || 0) * scale;
|
||||||
|
canvas.height = (currentPdf.imageHeight || 0) * scale;
|
||||||
|
|
||||||
|
ctx.drawImage(currentPdf.imageElement, 0, 0, canvas.width, canvas.height);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateZoomLabel = () => {
|
||||||
|
const label = document.getElementById('pdfZoomLabel');
|
||||||
|
if (label && currentPdf) {
|
||||||
|
label.textContent = `${Math.round(currentPdf.scale * 100)}%`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupCanvasEventHandlers = () => {
|
||||||
|
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
// Zoom buttons
|
||||||
|
const zoomOutBtn = document.getElementById('pdfZoomOut');
|
||||||
|
const zoomResetBtn = document.getElementById('pdfZoomReset');
|
||||||
|
const zoomInBtn = document.getElementById('pdfZoomIn');
|
||||||
|
|
||||||
|
if (zoomOutBtn) {
|
||||||
|
zoomOutBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
currentPdf.scale = Math.max(MIN_PDF_SCALE, currentPdf.scale - 0.1);
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
canvas.style.transform = 'translate(0px, 0px)';
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
updateZoomLabel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoomResetBtn) {
|
||||||
|
zoomResetBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
currentPdf.scale = DEFAULT_PDF_SCALE;
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
canvas.style.transform = 'translate(0px, 0px)';
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
updateZoomLabel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoomInBtn) {
|
||||||
|
zoomInBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
currentPdf.scale = Math.min(MAX_PDF_SCALE, currentPdf.scale + 0.1);
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
canvas.style.transform = 'translate(0px, 0px)';
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
updateZoomLabel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page navigation buttons (if multi-page)
|
||||||
|
const prevBtn = document.getElementById('pdfPrev');
|
||||||
|
const nextBtn = document.getElementById('pdfNext');
|
||||||
|
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf || currentPdf.page <= 1) return;
|
||||||
|
currentPdf.page--;
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
canvas.style.transform = 'translate(0px, 0px)';
|
||||||
|
await renderPdfPage();
|
||||||
|
document.getElementById('pdfPageNum')!.textContent = String(currentPdf.page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf || currentPdf.page >= currentPdf.pages) return;
|
||||||
|
currentPdf.page++;
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
canvas.style.transform = 'translate(0px, 0px)';
|
||||||
|
await renderPdfPage();
|
||||||
|
document.getElementById('pdfPageNum')!.textContent = String(currentPdf.page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas pan (mouse drag)
|
||||||
|
canvas.addEventListener('mousedown', (e) => {
|
||||||
|
pdfPan.dragging = true;
|
||||||
|
pdfPan.startX = e.clientX;
|
||||||
|
pdfPan.startY = e.clientY;
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mousemove', (e) => {
|
||||||
|
if (!pdfPan.dragging) return;
|
||||||
|
const dx = e.clientX - pdfPan.startX;
|
||||||
|
const dy = e.clientY - pdfPan.startY;
|
||||||
|
pdfPan.x = pdfPan.baseX + dx;
|
||||||
|
pdfPan.y = pdfPan.baseY + dy;
|
||||||
|
canvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mouseup', () => {
|
||||||
|
if (pdfPan.dragging) {
|
||||||
|
pdfPan.baseX = pdfPan.x;
|
||||||
|
pdfPan.baseY = pdfPan.y;
|
||||||
|
pdfPan.dragging = false;
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('mouseleave', () => {
|
||||||
|
if (pdfPan.dragging) {
|
||||||
|
pdfPan.baseX = pdfPan.x;
|
||||||
|
pdfPan.baseY = pdfPan.y;
|
||||||
|
pdfPan.dragging = false;
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pinch zoom (touch)
|
||||||
|
let lastDistance = 0;
|
||||||
|
canvas.addEventListener('touchstart', (e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
|
lastDistance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
} else if (e.touches.length === 1) {
|
||||||
|
pdfPan.dragging = true;
|
||||||
|
pdfPan.startX = e.touches[0].clientX;
|
||||||
|
pdfPan.startY = e.touches[0].clientY;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('touchmove', async (e) => {
|
||||||
|
if (e.touches.length === 2) {
|
||||||
|
e.preventDefault();
|
||||||
|
const dx = e.touches[0].clientX - e.touches[1].clientX;
|
||||||
|
const dy = e.touches[0].clientY - e.touches[1].clientY;
|
||||||
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||||
|
if (lastDistance > 0) {
|
||||||
|
const scaleDelta = (distance - lastDistance) / 100;
|
||||||
|
const newScale = currentPdf ? currentPdf.scale + scaleDelta : DEFAULT_PDF_SCALE;
|
||||||
|
if (currentPdf) {
|
||||||
|
currentPdf.scale = Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, newScale));
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
updateZoomLabel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastDistance = distance;
|
||||||
|
} else if (e.touches.length === 1 && pdfPan.dragging) {
|
||||||
|
const dx = e.touches[0].clientX - pdfPan.startX;
|
||||||
|
const dy = e.touches[0].clientY - pdfPan.startY;
|
||||||
|
pdfPan.x = pdfPan.baseX + dx;
|
||||||
|
pdfPan.y = pdfPan.baseY + dy;
|
||||||
|
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
|
||||||
|
if (canvas) {
|
||||||
|
canvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('touchend', () => {
|
||||||
|
pdfPan.baseX = pdfPan.x;
|
||||||
|
pdfPan.baseY = pdfPan.y;
|
||||||
|
pdfPan.dragging = false;
|
||||||
|
lastDistance = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wheel zoom (mouse scroll)
|
||||||
|
canvas.addEventListener('wheel', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentPdf) return;
|
||||||
|
const zoomDelta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
currentPdf.scale = Math.max(MIN_PDF_SCALE, Math.min(MAX_PDF_SCALE, currentPdf.scale + zoomDelta));
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
updateZoomLabel();
|
||||||
|
}, { passive: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Setup canvas event handlers for zoom, pan, and pinch
|
||||||
|
const setupPdfControls = () => {
|
||||||
|
const zoomInBtn = document.getElementById('pdfZoomInBtn') as HTMLButtonElement;
|
||||||
|
const zoomOutBtn = document.getElementById('pdfZoomOutBtn') as HTMLButtonElement;
|
||||||
|
const zoomResetBtn = document.getElementById('pdfZoomResetBtn') as HTMLButtonElement;
|
||||||
|
const canvas = document.getElementById('pdfCanvas') as HTMLCanvasElement;
|
||||||
|
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const applyPan = () => {
|
||||||
|
canvas.style.transform = `translate(${pdfPan.x}px, ${pdfPan.y}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const activePointers = new Map<number, {x: number; y: number}>();
|
||||||
|
let pinchStartDistance: number | null = null;
|
||||||
|
let pinchStartScale: number | null = null;
|
||||||
|
|
||||||
|
const distanceBetween = () => {
|
||||||
|
if (activePointers.size < 2) return null;
|
||||||
|
const pts = Array.from(activePointers.values());
|
||||||
|
const a = pts[0];
|
||||||
|
const b = pts[1];
|
||||||
|
const dx = a.x - b.x;
|
||||||
|
const dy = a.y - b.y;
|
||||||
|
return Math.hypot(dx, dy);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Zoom button handlers
|
||||||
|
if (zoomInBtn) {
|
||||||
|
zoomInBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
currentPdf.scale = Math.min(currentPdf.scale + 0.1, MAX_PDF_SCALE);
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoomOutBtn) {
|
||||||
|
zoomOutBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
currentPdf.scale = Math.max(currentPdf.scale - 0.1, MIN_PDF_SCALE);
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoomResetBtn) {
|
||||||
|
zoomResetBtn.addEventListener('click', async () => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
currentPdf.scale = currentPdf.isImage ? 1 : DEFAULT_PDF_SCALE;
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
canvas.style.transform = 'translate(0px, 0px)';
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canvas event handlers
|
||||||
|
canvas.style.touchAction = 'none';
|
||||||
|
|
||||||
|
canvas.addEventListener('wheel', async (e) => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY;
|
||||||
|
const step = 0.1;
|
||||||
|
if (delta < 0) {
|
||||||
|
currentPdf.scale = Math.min(currentPdf.scale + step, MAX_PDF_SCALE);
|
||||||
|
} else {
|
||||||
|
currentPdf.scale = Math.max(currentPdf.scale - step, MIN_PDF_SCALE);
|
||||||
|
}
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
await renderPdfPage();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
if (activePointers.size === 2) {
|
||||||
|
pinchStartDistance = distanceBetween();
|
||||||
|
pinchStartScale = currentPdf.scale;
|
||||||
|
pdfPan.dragging = false;
|
||||||
|
} else if (activePointers.size === 1) {
|
||||||
|
pdfPan.dragging = true;
|
||||||
|
pdfPan.startX = e.clientX;
|
||||||
|
pdfPan.startY = e.clientY;
|
||||||
|
pdfPan.baseX = pdfPan.x;
|
||||||
|
pdfPan.baseY = pdfPan.y;
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.setPointerCapture(e.pointerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (!currentPdf) return;
|
||||||
|
|
||||||
|
if (activePointers.has(e.pointerId)) {
|
||||||
|
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePointers.size >= 2 && pinchStartDistance) {
|
||||||
|
const dist = distanceBetween();
|
||||||
|
if (dist) {
|
||||||
|
const scale = pinchStartScale! * (dist / pinchStartDistance);
|
||||||
|
currentPdf.scale = Math.min(Math.max(scale, MIN_PDF_SCALE), MAX_PDF_SCALE);
|
||||||
|
if (currentPdf.isImage) {
|
||||||
|
renderImageOnCanvas();
|
||||||
|
} else {
|
||||||
|
renderPdfPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pdfPan.dragging) return;
|
||||||
|
const dx = e.clientX - pdfPan.startX;
|
||||||
|
const dy = e.clientY - pdfPan.startY;
|
||||||
|
pdfPan.x = pdfPan.baseX + dx;
|
||||||
|
pdfPan.y = pdfPan.baseY + dy;
|
||||||
|
applyPan();
|
||||||
|
});
|
||||||
|
|
||||||
|
const endPan = (e: PointerEvent) => {
|
||||||
|
if (activePointers.has(e.pointerId)) {
|
||||||
|
activePointers.delete(e.pointerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePointers.size < 2) {
|
||||||
|
pinchStartDistance = null;
|
||||||
|
pinchStartScale = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activePointers.size === 0) {
|
||||||
|
pdfPan.dragging = false;
|
||||||
|
canvas.style.cursor = 'grab';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
canvas.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerup', endPan);
|
||||||
|
canvas.addEventListener('pointercancel', endPan);
|
||||||
|
};
|
||||||
|
|
||||||
// Search and filters
|
// Search and filters
|
||||||
let searchQuery = '';
|
let searchQuery = '';
|
||||||
let showFiltersPanel = false;
|
let showFiltersPanel = false;
|
||||||
@@ -111,6 +506,15 @@
|
|||||||
let showFileViewer = false;
|
let showFileViewer = false;
|
||||||
let currentViewerFile: any = null;
|
let currentViewerFile: any = null;
|
||||||
let graphToken = '';
|
let graphToken = '';
|
||||||
|
|
||||||
|
// PDF/Image viewer state
|
||||||
|
let currentPreviewUrl: string | null = null;
|
||||||
|
let currentPdf: {doc: any; page: number; pages: number; scale: number; isImage?: boolean; imageElement?: HTMLImageElement; imageWidth?: number; imageHeight?: number} | null = null;
|
||||||
|
let pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
let showPdfViewer = false;
|
||||||
|
const DEFAULT_PDF_SCALE = 0.45;
|
||||||
|
const MIN_PDF_SCALE = 0.3;
|
||||||
|
const MAX_PDF_SCALE = 3.0;
|
||||||
let graphTokenExpiry: number | null = null; // Expiration timestamp in ms
|
let graphTokenExpiry: number | null = null; // Expiration timestamp in ms
|
||||||
let graphTokenRefreshTimer: NodeJS.Timeout | null = null; // Background refresh timer
|
let graphTokenRefreshTimer: NodeJS.Timeout | null = null; // Background refresh timer
|
||||||
|
|
||||||
@@ -487,11 +891,108 @@
|
|||||||
currentViewerFile = file;
|
currentViewerFile = file;
|
||||||
showFileViewer = true;
|
showFileViewer = true;
|
||||||
showDetailModal = false;
|
showDetailModal = false;
|
||||||
|
|
||||||
|
// Try to preview in canvas if supported
|
||||||
|
const ext = (file.name || '').toLowerCase().split('.').pop();
|
||||||
|
if (canPreviewInline(file.name, file.contentType || '')) {
|
||||||
|
loadFilePreview(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilePreview(file: any) {
|
||||||
|
if (!file.url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let fetchUrl = '';
|
||||||
|
|
||||||
|
// Use backend proxy endpoints instead of direct SharePoint URLs
|
||||||
|
const ext = (file.name || '').toLowerCase().split('.').pop();
|
||||||
|
const isWordDoc = ['doc', 'docx'].includes(ext as string) || file.contentType?.includes('word');
|
||||||
|
const isPdf = ext === 'pdf' || file.contentType?.includes('pdf');
|
||||||
|
|
||||||
|
if (file.driveId && file.id) {
|
||||||
|
// Use proxy endpoints with Graph API
|
||||||
|
if (isWordDoc) {
|
||||||
|
fetchUrl = `/api/file-pdf?driveId=${encodeURIComponent(file.driveId)}&itemId=${encodeURIComponent(file.id)}`;
|
||||||
|
} else {
|
||||||
|
fetchUrl = `/api/file-content?driveId=${encodeURIComponent(file.driveId)}&itemId=${encodeURIComponent(file.id)}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fallback to direct URL (for non-SharePoint files)
|
||||||
|
fetchUrl = file.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(fetchUrl, {
|
||||||
|
headers: graphToken ? { 'x-graph-token': graphToken } : {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch file: ${response.status}`);
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (currentPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(currentPreviewUrl);
|
||||||
|
}
|
||||||
|
currentPreviewUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'bmp'].includes(ext as string) || file.contentType?.includes('image');
|
||||||
|
|
||||||
|
if ((isPdf || isWordDoc) && (window as any).pdfjsLib) {
|
||||||
|
const data = await blob.arrayBuffer();
|
||||||
|
const pdfjsLib = (window as any).pdfjsLib;
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
|
||||||
|
|
||||||
|
const doc = await pdfjsLib.getDocument({ data }).promise;
|
||||||
|
currentPdf = {
|
||||||
|
doc,
|
||||||
|
page: 1,
|
||||||
|
pages: doc.numPages,
|
||||||
|
scale: DEFAULT_PDF_SCALE,
|
||||||
|
};
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
showPdfViewer = true;
|
||||||
|
|
||||||
|
// Render first page and setup controls
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
await renderPdfPage();
|
||||||
|
updateZoomLabel();
|
||||||
|
setupCanvasEventHandlers();
|
||||||
|
} else if (isImage) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
currentPdf = {
|
||||||
|
doc: null,
|
||||||
|
page: 1,
|
||||||
|
pages: 1,
|
||||||
|
scale: 1,
|
||||||
|
isImage: true,
|
||||||
|
imageElement: img,
|
||||||
|
imageWidth: img.width,
|
||||||
|
imageHeight: img.height,
|
||||||
|
};
|
||||||
|
pdfPan = { x: 0, y: 0, dragging: false, startX: 0, startY: 0, baseX: 0, baseY: 0 };
|
||||||
|
showPdfViewer = true;
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
renderImageOnCanvas();
|
||||||
|
updateZoomLabel();
|
||||||
|
setupCanvasEventHandlers();
|
||||||
|
};
|
||||||
|
img.src = currentPreviewUrl;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Preview load error:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeFileViewer() {
|
function closeFileViewer() {
|
||||||
showFileViewer = false;
|
showFileViewer = false;
|
||||||
|
showPdfViewer = false;
|
||||||
currentViewerFile = null;
|
currentViewerFile = null;
|
||||||
|
currentPdf = null;
|
||||||
|
if (currentPreviewUrl) {
|
||||||
|
URL.revokeObjectURL(currentPreviewUrl);
|
||||||
|
currentPreviewUrl = null;
|
||||||
|
}
|
||||||
showDetailModal = true;
|
showDetailModal = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -967,8 +1468,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Viewer Content -->
|
<!-- Viewer Content -->
|
||||||
<div style="flex: 1; overflow: auto; background: #000; display: flex; align-items: center; justify-content: center;">
|
<div style="flex: 1; overflow: auto; background: #000; display: flex; align-items: center; justify-content: center; flex-direction: column;">
|
||||||
{#if currentViewerFile.url}
|
{#if showPdfViewer && currentPdf}
|
||||||
|
<!-- PDF/Image Controls -->
|
||||||
|
<div style="background: #1f2937; color: white; width: 100%; padding: 8px 12px; display: flex; align-items: center; gap: 12px; border-bottom: 1px solid #374151; flex-shrink: 0;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<button
|
||||||
|
id="pdfZoomOutBtn"
|
||||||
|
style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: bold;"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="pdfZoomResetBtn"
|
||||||
|
style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: bold;"
|
||||||
|
>
|
||||||
|
{Math.round(currentPdf.scale * 100)}%
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="pdfZoomInBtn"
|
||||||
|
style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px; font-weight: bold;"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<span style="font-size: 11px; color: #d1d5db; margin-left: 6px;">
|
||||||
|
{Math.round(currentPdf.scale * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if !currentPdf.isImage && currentPdf.pages > 1}
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; margin-left: 12px;">
|
||||||
|
<button style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px;">◀</button>
|
||||||
|
<button style="padding: 4px 8px; background: #6b7280; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 11px;">▶</button>
|
||||||
|
<span style="font-size: 11px; color: #d1d5db;">Page <span>{currentPdf.page}</span> / <span>{currentPdf.pages}</span></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Canvas Viewer -->
|
||||||
|
<div style="flex: 1; overflow: hidden; background: #111; display: flex; align-items: center; justify-content: center; width: 100%;">
|
||||||
|
<canvas
|
||||||
|
id="pdfCanvas"
|
||||||
|
style="max-width: 100%; max-height: 100%; object-fit: contain; background: white; cursor: grab; touch-action: none;"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
{:else if currentViewerFile.url}
|
||||||
<iframe
|
<iframe
|
||||||
src={currentViewerFile.url}
|
src={currentViewerFile.url}
|
||||||
style="width: 100%; height: 100%; border: none;"
|
style="width: 100%; height: 100%; border: none;"
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy endpoint to fetch file content from SharePoint
|
||||||
|
* Bypasses CORS restrictions by using backend authentication
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ request, url }) => {
|
||||||
|
try {
|
||||||
|
// Get Graph token from request header or environment
|
||||||
|
const headerToken = request.headers.get('x-graph-token') || '';
|
||||||
|
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
||||||
|
const token = headerToken || envToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return json({ error: 'GRAPH_TOKEN missing' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveId = url.searchParams.get('driveId');
|
||||||
|
const itemId = url.searchParams.get('itemId');
|
||||||
|
|
||||||
|
if (!driveId || !itemId) {
|
||||||
|
return json({ error: 'driveId and itemId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from Microsoft Graph API
|
||||||
|
const graphUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content`;
|
||||||
|
const res = await fetch(graphUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
console.error(`[file-content] Graph API error: ${res.status} - ${text}`);
|
||||||
|
return json({ error: `Graph ${res.status}: ${text}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the content type and disposition headers
|
||||||
|
const contentType = res.headers.get('content-type') || 'application/octet-stream';
|
||||||
|
const contentDisposition = res.headers.get('content-disposition') || '';
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': contentType,
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
headers['Content-Disposition'] = contentDisposition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the file as a response
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
return new Response(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[file-content] Error:', message);
|
||||||
|
return json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proxy endpoint to fetch and convert Word documents to PDF
|
||||||
|
* Uses Microsoft Graph API with ?format=pdf parameter
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ request, url }) => {
|
||||||
|
try {
|
||||||
|
// Get Graph token from request header or environment
|
||||||
|
const headerToken = request.headers.get('x-graph-token') || '';
|
||||||
|
const envToken = process.env.GRAPH_TOKEN || process.env.MS_GRAPH_TOKEN || '';
|
||||||
|
const token = headerToken || envToken;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return json({ error: 'GRAPH_TOKEN missing' }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveId = url.searchParams.get('driveId');
|
||||||
|
const itemId = url.searchParams.get('itemId');
|
||||||
|
|
||||||
|
if (!driveId || !itemId) {
|
||||||
|
return json({ error: 'driveId and itemId required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use Graph API to convert to PDF format
|
||||||
|
const graphUrl = `https://graph.microsoft.com/v1.0/drives/${driveId}/items/${itemId}/content?format=pdf`;
|
||||||
|
const res = await fetch(graphUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
console.error(`[file-pdf] Conversion failed: ${res.status} - ${text}`);
|
||||||
|
return json({ error: `PDF conversion failed: ${res.status}` }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/pdf',
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update filename to .pdf if present
|
||||||
|
const contentDisposition = res.headers.get('content-disposition');
|
||||||
|
if (contentDisposition) {
|
||||||
|
headers['Content-Disposition'] = contentDisposition.replace(/\.(docx?)/gi, '.pdf');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[file-pdf] Successfully converted: driveId=${driveId}, itemId=${itemId}`);
|
||||||
|
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
return new Response(buffer, {
|
||||||
|
status: 200,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.error('[file-pdf] Error:', message);
|
||||||
|
return json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user