Update 1.2.0
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { userStore } from '../ts/auth';
|
||||
import { commentReference } from '../ts/store';
|
||||
|
||||
let { docId, onClose } = $props<{ docId: string, onClose: () => void }>();
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
document_id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
resolved: boolean;
|
||||
created_at: string;
|
||||
author_name?: string;
|
||||
};
|
||||
|
||||
let comments = $state<Comment[]>([]);
|
||||
let newCommentContent = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
async function fetchComments() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/docs/${docId}/comments`);
|
||||
if (!res.ok) throw new Error('Failed to load comments');
|
||||
comments = await res.json();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function postComment() {
|
||||
if (!newCommentContent.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`/api/docs/${docId}/comments`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content: newCommentContent })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to post comment');
|
||||
const c: Comment = await res.json();
|
||||
comments = [...comments, c];
|
||||
newCommentContent = '';
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComment(id: string) {
|
||||
if (!confirm('Are you sure you want to delete this comment?')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('Failed to delete comment');
|
||||
comments = comments.filter(c => c.id !== id);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleResolve(comment: Comment) {
|
||||
try {
|
||||
const res = await fetch(`/api/comments/${comment.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ resolved: !comment.resolved })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update comment');
|
||||
const updated: Comment = await res.json();
|
||||
comments = comments.map(c => c.id === comment.id ? updated : c);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($commentReference) {
|
||||
newCommentContent = `> ${$commentReference}\n\n`;
|
||||
$commentReference = '';
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
fetchComments();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="fixed right-0 top-0 bottom-0 w-80 bg-[var(--theme-bg)] backdrop-blur-xl border-l shadow-2xl flex flex-col z-[70] transform transition-transform duration-300 border-[var(--theme-border)]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50/50 bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="mdi:comment-text-multiple-outline" class="text-lg" />
|
||||
<h2 class="text-sm font-semibold text-[var(--theme-text)]">Comments</h2>
|
||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full">{comments.length}</span>
|
||||
</div>
|
||||
<button onclick={onClose} class="p-1.5 hover:text-gray-600 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-white/10 rounded-md transition-colors" title="Close Comments">
|
||||
<Icon icon="mdi:close" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Feed -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{#if loading}
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<Icon icon="mdi:loading" class="animate-spin text-2xl" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-red-500 text-sm text-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-900/30">
|
||||
{error}
|
||||
</div>
|
||||
{:else if comments.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full space-y-2">
|
||||
<Icon icon="mdi:comment-off-outline" class="text-4xl opacity-50" />
|
||||
<p class="text-sm">No comments yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each comments as comment}
|
||||
<div class="group flex flex-col gap-2 p-3 border rounded-xl shadow-sm hover:shadow-md transition-all {comment.resolved ? 'opacity-60' : ''} bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 flex items-center justify-center text-xs font-bold">
|
||||
{(comment.author_name || 'A').substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-[var(--theme-text)]">{comment.author_name || 'Anonymous'}</p>
|
||||
<p class="text-[10px]">{formatDate(comment.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex opacity-0 group-hover:opacity-100 transition-opacity gap-1">
|
||||
{#if $userStore?.id === comment.user_id}
|
||||
<button onclick={() => deleteComment(comment.id)} class="p-1 hover:text-red-500 rounded hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors" title="Delete">
|
||||
<Icon icon="mdi:trash-can-outline" class="text-xs" />
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => toggleResolve(comment)} class="p-1 hover:text-emerald-500 rounded hover:bg-emerald-50 dark:hover:bg-emerald-500/10 transition-colors" title={comment.resolved ? "Reopen" : "Resolve"}>
|
||||
<Icon icon={comment.resolved ? "mdi:check-circle" : "mdi:check-circle-outline"} class="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm leading-relaxed whitespace-pre-wrap">{comment.content}</p>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="p-4 border-t bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||
<div class="relative">
|
||||
<textarea
|
||||
bind:value={newCommentContent}
|
||||
placeholder="Add a comment..."
|
||||
class="w-full border text-[var(--theme-text)] text-sm rounded-xl px-3 py-2.5 pr-10 focus:outline-none focus:ring-2 focus:ring-blue-500/50 resize-none min-h-[80px] bg-[var(--theme-bg)] border-[var(--theme-border)]"
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
postComment();
|
||||
}
|
||||
}}
|
||||
></textarea>
|
||||
<button
|
||||
onclick={postComment}
|
||||
disabled={!newCommentContent.trim()}
|
||||
class="absolute bottom-2.5 right-2.5 p-1.5 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-300 dark:disabled:bg-zinc-700 disabled:text-gray-500 rounded-lg transition-colors"
|
||||
title="Post (Enter)"
|
||||
>
|
||||
<Icon icon="mdi:send" class="text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] mt-2 text-center">Press <kbd class="font-mono px-1 py-0.5 rounded">Enter</kbd> to post, <kbd class="font-mono px-1 py-0.5 rounded">Shift+Enter</kbd> for newline</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,456 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let { onClose } = $props<{ onClose: () => void }>();
|
||||
|
||||
let svgs = $state<string[]>([]);
|
||||
let currentSlide = $state(0);
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||
let activeCanvas = $state<HTMLCanvasElement | null>(null);
|
||||
let ctx: CanvasRenderingContext2D | null = null;
|
||||
let activeCtx: CanvasRenderingContext2D | null = null;
|
||||
let isDrawing = false;
|
||||
let currentPath = $state<{x: number, y: number}[]>([]);
|
||||
|
||||
// New feature states
|
||||
let tool = $state<'pen' | 'highlighter' | 'eraser' | 'laser'>('laser');
|
||||
let selectedColor = $state('#ef4444');
|
||||
let showGrid = $state(false);
|
||||
let laserPos = $state({ x: 0, y: 0, visible: false });
|
||||
let uiVisible = $state(true);
|
||||
|
||||
const colors = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#3b82f6', '#a855f7', '#ffffff', '#000000'];
|
||||
|
||||
// Map from slide index to image data url so we can persist drawings when switching slides
|
||||
let drawings = $state<Record<number, string>>({});
|
||||
let undoStack = $state<Record<number, string[]>>({});
|
||||
let redoStack = $state<Record<number, string[]>>({});
|
||||
|
||||
let inactivityTimeout: number;
|
||||
|
||||
function resetInactivityTimeout() {
|
||||
uiVisible = true;
|
||||
if (inactivityTimeout) window.clearTimeout(inactivityTimeout);
|
||||
inactivityTimeout = window.setTimeout(() => {
|
||||
if (!showGrid && !isDrawing) {
|
||||
uiVisible = false;
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Find the preview svgs from the main DOM
|
||||
const previewContainers = document.querySelectorAll('.preview-container svg');
|
||||
const svgStrings: string[] = [];
|
||||
previewContainers.forEach(container => {
|
||||
svgStrings.push(container.outerHTML);
|
||||
});
|
||||
svgs = svgStrings;
|
||||
|
||||
// Request fullscreen
|
||||
const el = document.getElementById('presentation-container');
|
||||
if (el && el.requestFullscreen) {
|
||||
el.requestFullscreen().catch(err => console.error(err));
|
||||
}
|
||||
|
||||
const handleFullscreenChange = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key.toLowerCase() === 'g') {
|
||||
showGrid = !showGrid;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowDown' || e.key === ' ') {
|
||||
nextSlide();
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
|
||||
prevSlide();
|
||||
} else if (e.key === 'Escape') {
|
||||
if (showGrid) {
|
||||
showGrid = false;
|
||||
} else if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('mousemove', resetInactivityTimeout);
|
||||
window.addEventListener('mousedown', resetInactivityTimeout);
|
||||
window.addEventListener('touchstart', resetInactivityTimeout);
|
||||
|
||||
resetInactivityTimeout();
|
||||
|
||||
return () => {
|
||||
clearTimeout(inactivityTimeout);
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('mousemove', resetInactivityTimeout);
|
||||
window.removeEventListener('mousedown', resetInactivityTimeout);
|
||||
window.removeEventListener('touchstart', resetInactivityTimeout);
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (canvas && currentSlide !== undefined && !showGrid) {
|
||||
// Resize canvas to match the svg
|
||||
const svgEl = document.getElementById('presentation-svg')?.querySelector('svg');
|
||||
if (svgEl) {
|
||||
const rect = svgEl.getBoundingClientRect();
|
||||
canvas.width = rect.width;
|
||||
canvas.height = rect.height;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
if (activeCanvas) {
|
||||
activeCanvas.width = rect.width;
|
||||
activeCanvas.height = rect.height;
|
||||
activeCtx = activeCanvas.getContext('2d');
|
||||
if (activeCtx) {
|
||||
activeCtx.lineCap = 'round';
|
||||
activeCtx.lineJoin = 'round';
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx) {
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Load previous drawing if any
|
||||
if (drawings[currentSlide]) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx?.drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = drawings[currentSlide];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function hexToRgba(hex: string, alpha: number) {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
saveDrawing();
|
||||
if (currentSlide < svgs.length - 1) currentSlide++;
|
||||
}
|
||||
|
||||
function prevSlide() {
|
||||
saveDrawing();
|
||||
if (currentSlide > 0) currentSlide--;
|
||||
}
|
||||
|
||||
function saveDrawing() {
|
||||
if (canvas) {
|
||||
drawings[currentSlide] = canvas.toDataURL();
|
||||
}
|
||||
}
|
||||
|
||||
function startDrawing(e: MouseEvent | TouchEvent) {
|
||||
if (tool === 'laser') return;
|
||||
isDrawing = true;
|
||||
|
||||
if (!undoStack[currentSlide]) undoStack[currentSlide] = [];
|
||||
if (canvas) undoStack[currentSlide].push(canvas.toDataURL());
|
||||
redoStack[currentSlide] = [];
|
||||
|
||||
currentPath = [];
|
||||
addPointToPath(e);
|
||||
}
|
||||
|
||||
function addPointToPath(e: MouseEvent | TouchEvent) {
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
const x = clientX - rect.left;
|
||||
const y = clientY - rect.top;
|
||||
currentPath.push({x, y});
|
||||
}
|
||||
|
||||
function stopDrawing() {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
if (tool !== 'eraser' && ctx && activeCanvas && activeCtx) {
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
ctx.drawImage(activeCanvas, 0, 0);
|
||||
activeCtx.clearRect(0, 0, activeCanvas.width, activeCanvas.height);
|
||||
}
|
||||
saveDrawing();
|
||||
}
|
||||
|
||||
function handlePointerMove(e: MouseEvent | TouchEvent) {
|
||||
if (tool === 'laser') {
|
||||
laserPos.visible = true;
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
laserPos.x = clientX;
|
||||
laserPos.y = clientY;
|
||||
} else {
|
||||
laserPos.visible = false;
|
||||
if (isDrawing) draw(e);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
stopDrawing();
|
||||
laserPos.visible = false;
|
||||
}
|
||||
|
||||
function draw(e: MouseEvent | TouchEvent) {
|
||||
if (!isDrawing || !activeCtx || !canvas || tool === 'laser') return;
|
||||
e.preventDefault();
|
||||
|
||||
addPointToPath(e);
|
||||
|
||||
if (tool === 'eraser') {
|
||||
if (ctx) {
|
||||
ctx.globalCompositeOperation = 'destination-out';
|
||||
ctx.lineWidth = 30;
|
||||
ctx.strokeStyle = 'rgba(0,0,0,1)';
|
||||
|
||||
const prev = currentPath[currentPath.length - 2] || currentPath[0];
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(prev.x, prev.y);
|
||||
ctx.lineTo(currentPath[currentPath.length - 1].x, currentPath[currentPath.length - 1].y);
|
||||
ctx.stroke();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeCanvas) {
|
||||
activeCtx.clearRect(0, 0, activeCanvas.width, activeCanvas.height);
|
||||
activeCtx.beginPath();
|
||||
activeCtx.moveTo(currentPath[0].x, currentPath[0].y);
|
||||
for (let i = 1; i < currentPath.length; i++) {
|
||||
activeCtx.lineTo(currentPath[i].x, currentPath[i].y);
|
||||
}
|
||||
|
||||
if (tool === 'pen') {
|
||||
activeCtx.globalCompositeOperation = 'source-over';
|
||||
activeCtx.lineWidth = 3;
|
||||
activeCtx.strokeStyle = selectedColor;
|
||||
} else if (tool === 'highlighter') {
|
||||
activeCtx.globalCompositeOperation = 'source-over';
|
||||
activeCtx.lineWidth = 20;
|
||||
activeCtx.strokeStyle = hexToRgba(selectedColor, 0.4);
|
||||
}
|
||||
activeCtx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
function clearSlide() {
|
||||
if (ctx && canvas) {
|
||||
if (!undoStack[currentSlide]) undoStack[currentSlide] = [];
|
||||
undoStack[currentSlide].push(canvas.toDataURL());
|
||||
redoStack[currentSlide] = [];
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
delete drawings[currentSlide];
|
||||
}
|
||||
}
|
||||
|
||||
function undo() {
|
||||
if (!undoStack[currentSlide] || undoStack[currentSlide].length === 0) return;
|
||||
if (!redoStack[currentSlide]) redoStack[currentSlide] = [];
|
||||
if (canvas) redoStack[currentSlide].push(canvas.toDataURL());
|
||||
|
||||
const prevState = undoStack[currentSlide].pop();
|
||||
applyState(prevState);
|
||||
}
|
||||
|
||||
function redo() {
|
||||
if (!redoStack[currentSlide] || redoStack[currentSlide].length === 0) return;
|
||||
if (!undoStack[currentSlide]) undoStack[currentSlide] = [];
|
||||
if (canvas) undoStack[currentSlide].push(canvas.toDataURL());
|
||||
|
||||
const nextState = redoStack[currentSlide].pop();
|
||||
applyState(nextState);
|
||||
}
|
||||
|
||||
function applyState(dataUrl: string | undefined) {
|
||||
if (ctx && canvas) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (dataUrl) {
|
||||
const img = new Image();
|
||||
img.onload = () => ctx?.drawImage(img, 0, 0);
|
||||
img.src = dataUrl;
|
||||
drawings[currentSlide] = dataUrl;
|
||||
} else {
|
||||
delete drawings[currentSlide];
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="presentation-container" class="fixed inset-0 z-[100] flex flex-col items-center justify-center">
|
||||
|
||||
<!-- Laser Pointer Overlay -->
|
||||
{#if tool === 'laser' && laserPos.visible && !showGrid}
|
||||
<div
|
||||
class="pointer-events-none fixed z-[150] w-3 h-3 bg-red-500 rounded-full shadow-[0_0_15px_5px_rgba(239,68,68,0.8)] -translate-x-1/2 -translate-y-1/2"
|
||||
style="left: {laserPos.x}px; top: {laserPos.y}px;"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Thumbnail Grid UI -->
|
||||
{#if showGrid}
|
||||
<div class="absolute inset-0 z-[50] p-8 overflow-y-auto">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6 max-w-7xl mx-auto pb-24">
|
||||
{#each svgs as svg, i}
|
||||
<button
|
||||
class="relative aspect-video rounded-lg overflow-hidden shadow-lg border-4 transition-all focus:outline-none {currentSlide === i ? 'border-blue-500 scale-105 shadow-blue-500/20' : 'border-transparent hover:border-white/50'} bg-[var(--theme-bg)] text-[var(--theme-text)]"
|
||||
onclick={() => { currentSlide = i; showGrid = false; }}
|
||||
>
|
||||
<div class="w-full h-full pointer-events-none flex items-center justify-center p-2 presentation-grid-svg">
|
||||
{@html svg}
|
||||
</div>
|
||||
<div class="absolute bottom-2 right-2 bg-black/60 text-xs font-mono px-2 py-1 rounded-md backdrop-blur-sm">
|
||||
{i + 1}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Top toolbar -->
|
||||
<div class="absolute top-0 inset-x-0 h-16 bg-gradient-to-b from-black/80 to-transparent flex items-center justify-between px-6 transition-opacity duration-300 z-[110] {uiVisible || showGrid ? 'opacity-100' : 'opacity-0'}">
|
||||
<div class="flex items-center gap-4 bg-zinc-900/80 backdrop-blur-md px-4 py-2 rounded-xl border shadow-2xl border-[var(--theme-border)]">
|
||||
<button class="p-2 rounded-lg transition-colors {tool === 'laser' ? 'bg-red-500/20 text-red-400' : 'text-gray-300 hover:bg-white/10'}" onclick={() => tool = 'laser'} title="Laser Pointer">
|
||||
<Icon icon="mdi:laser-pointer" class="text-xl" />
|
||||
</button>
|
||||
<button class="p-2 rounded-lg transition-colors {tool === 'pen' ? 'bg-blue-500/20 text-blue-400' : 'text-gray-300 hover:bg-white/10'}" onclick={() => tool = 'pen'} title="Pen">
|
||||
<Icon icon="mdi:lead-pencil" class="text-xl" />
|
||||
</button>
|
||||
<button class="p-2 rounded-lg transition-colors {tool === 'highlighter' ? 'bg-yellow-500/20 text-yellow-400' : 'text-gray-300 hover:bg-white/10'}" onclick={() => tool = 'highlighter'} title="Highlighter">
|
||||
<Icon icon="mdi:marker" class="text-xl" />
|
||||
</button>
|
||||
<button class="p-2 rounded-lg transition-colors {tool === 'eraser' ? 'bg-white/20 text-white' : 'text-gray-300 hover:bg-white/10'}" onclick={() => tool = 'eraser'} title="Eraser">
|
||||
<Icon icon="mdi:eraser" class="text-xl" />
|
||||
</button>
|
||||
|
||||
{#if tool === 'pen' || tool === 'highlighter'}
|
||||
<div class="w-px h-6 bg-white/10 mx-1"></div>
|
||||
<div class="flex gap-1.5">
|
||||
{#each colors as color}
|
||||
<button
|
||||
class="w-5 h-5 rounded-full border transition-transform hover:scale-110 {selectedColor === color ? 'ring-2 ring-white ring-offset-2 ring-offset-zinc-900' : ''} border-[var(--theme-border)]"
|
||||
style="background-color: {color};"
|
||||
onclick={() => selectedColor = color}
|
||||
title="Select Color"
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="w-px h-6 bg-white/10 mx-1"></div>
|
||||
<button class="p-2 rounded-lg hover:bg-white/20 hover:text-white transition-colors" onclick={undo} disabled={!undoStack[currentSlide]?.length} title="Undo">
|
||||
<Icon icon="mdi:undo" class="text-xl" />
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-white/20 hover:text-white transition-colors" onclick={redo} disabled={!redoStack[currentSlide]?.length} title="Redo">
|
||||
<Icon icon="mdi:redo" class="text-xl" />
|
||||
</button>
|
||||
<button class="p-2 rounded-lg hover:bg-red-500/20 hover:text-red-400 transition-colors" onclick={clearSlide} title="Clear Drawings">
|
||||
<Icon icon="mdi:delete-sweep-outline" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button onclick={() => showGrid = !showGrid} class="p-2 text-white/70 hover:text-white bg-black/50 hover:bg-white/10 rounded-full transition-colors flex items-center justify-center w-10 h-10" title="Toggle Grid (G)">
|
||||
<Icon icon="mdi:view-grid" class="text-xl" />
|
||||
</button>
|
||||
<button onclick={() => { if(document.fullscreenElement) document.exitFullscreen(); onClose(); }} class="p-2 text-white/70 hover:text-white bg-black/50 hover:bg-white/10 rounded-full transition-colors flex items-center justify-center w-10 h-10" title="Exit Presentation">
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slide Area -->
|
||||
<div class="relative w-full h-full flex items-center justify-center p-8">
|
||||
{#if svgs.length > 0 && !showGrid}
|
||||
<div id="presentation-svg" class="relative max-h-full max-w-full shadow-2xl flex items-center justify-center bg-[var(--theme-bg)] text-[var(--theme-text)]">
|
||||
{@html svgs[currentSlide]}
|
||||
|
||||
<!-- Drawing Canvas Overlay -->
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
class="absolute inset-0 z-10 touch-none pointer-events-none"
|
||||
></canvas>
|
||||
|
||||
<!-- Active Stroke Canvas Overlay -->
|
||||
<canvas
|
||||
bind:this={activeCanvas}
|
||||
class="absolute inset-0 z-20 touch-none {tool === 'laser' ? 'cursor-none' : 'cursor-crosshair'}"
|
||||
onmousedown={startDrawing}
|
||||
onmousemove={handlePointerMove}
|
||||
onmouseup={stopDrawing}
|
||||
onmouseleave={handlePointerLeave}
|
||||
ontouchstart={startDrawing}
|
||||
ontouchmove={handlePointerMove}
|
||||
ontouchend={handlePointerLeave}
|
||||
></canvas>
|
||||
</div>
|
||||
{:else if svgs.length === 0}
|
||||
<div class="text-white/50 text-xl">No slides available to present.</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Bottom Navigation -->
|
||||
{#if svgs.length > 0}
|
||||
<div class="absolute bottom-6 flex items-center gap-4 bg-zinc-900/80 backdrop-blur-md px-6 py-3 rounded-full border shadow-2xl transition-opacity duration-300 z-[110] {uiVisible || showGrid ? 'opacity-100' : 'opacity-0'} border-[var(--theme-border)]">
|
||||
{#if svgs.length > 1}
|
||||
<button onclick={prevSlide} disabled={currentSlide === 0} class="p-2 hover:bg-white/20 disabled:opacity-30 disabled:hover:bg-transparent rounded-full transition-all">
|
||||
<Icon icon="mdi:chevron-left" class="text-3xl" />
|
||||
</button>
|
||||
<button onclick={() => showGrid = !showGrid} class="font-mono font-semibold text-lg text-white/90 min-w-[3rem] text-center hover:bg-white/10 px-2 py-1 rounded-md transition-colors" title="Show Grid (G)">
|
||||
{currentSlide + 1} / {svgs.length}
|
||||
</button>
|
||||
<button onclick={nextSlide} disabled={currentSlide === svgs.length - 1} class="p-2 hover:bg-white/20 disabled:opacity-30 disabled:hover:bg-transparent rounded-full transition-all">
|
||||
<Icon icon="mdi:chevron-right" class="text-3xl" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(#presentation-svg svg) {
|
||||
max-height: calc(100vh - 4rem);
|
||||
max-width: calc(100vw - 4rem);
|
||||
height: 100%;
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
:global(.presentation-grid-svg) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
:global(.presentation-grid-svg svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -9,6 +9,43 @@
|
||||
let copied = $state(false);
|
||||
let role = $state('editor');
|
||||
|
||||
let inviteEmail = $state('');
|
||||
let inviteRole = $state('editor');
|
||||
let inviteStatus = $state<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
let inviteMessage = $state('');
|
||||
|
||||
async function inviteUser(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!docId || !inviteEmail.trim()) return;
|
||||
|
||||
inviteStatus = 'loading';
|
||||
inviteMessage = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/docs/${docId}/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email: inviteEmail.trim(), role: inviteRole })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
inviteStatus = 'success';
|
||||
inviteMessage = 'User invited successfully!';
|
||||
inviteEmail = '';
|
||||
} else {
|
||||
const text = await res.text();
|
||||
inviteStatus = 'error';
|
||||
inviteMessage = text || 'Failed to invite user';
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
inviteStatus = 'error';
|
||||
inviteMessage = 'Network error occurred';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
|
||||
const baseUrl = window.location.origin;
|
||||
@@ -45,52 +82,62 @@
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200" role="presentation" onclick={onClose}>
|
||||
<div tabindex="-1" class="bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 w-full max-w-md overflow-hidden" role="dialog" aria-modal="true" aria-labelledby="share-dialog-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||
<div class="flex justify-between items-center p-4 border-b border-gray-100 dark:border-zinc-800">
|
||||
<h2 id="share-dialog-title" class="text-lg font-semibold text-gray-900 dark:text-white">Share Document</h2>
|
||||
<div tabindex="-1" class="rounded-xl shadow-2xl border w-full max-w-[500px] overflow-hidden bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]" style="background-color: var(--theme-bg); color: var(--theme-text); border-color: var(--theme-border);" role="dialog" aria-modal="true" aria-labelledby="share-dialog-title" onclick={(e) => e.stopPropagation()} onkeydown={(e) => { if (e.key === 'Escape') onClose(); }}>
|
||||
<div class="flex justify-between items-center p-4 border-b border-[var(--theme-border)]" style="border-color: var(--theme-border);">
|
||||
<h2 id="share-dialog-title" class="text-lg font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">Share Document</h2>
|
||||
<button onclick={onClose} aria-label="Close" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-full p-1 transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label for="share-link-input" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">Share Link</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">Invite Collaborator</div>
|
||||
<form onsubmit={inviteUser} class="flex items-center gap-2 bg-gray-50 dark:bg-zinc-900/50 p-1.5 rounded-lg border border-gray-300 dark:border-zinc-700 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-all">
|
||||
<div class="pl-2 text-gray-400">
|
||||
<Icon icon="mdi:account-plus-outline" class="text-xl" />
|
||||
</div>
|
||||
<input
|
||||
id="share-link-input"
|
||||
type="text"
|
||||
readonly
|
||||
value={link}
|
||||
class="flex-1 bg-gray-50 dark:bg-zinc-950 border border-gray-300 dark:border-zinc-700 text-gray-600 dark:text-gray-400 text-sm rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
type="email"
|
||||
placeholder="Add people via email..."
|
||||
bind:value={inviteEmail}
|
||||
required
|
||||
class="flex-1 bg-transparent border-none text-gray-800 dark:text-gray-200 text-sm px-2 py-2 focus:ring-0 focus:outline-none w-full"
|
||||
/>
|
||||
<div class="h-6 w-px bg-gray-300 dark:bg-zinc-700"></div>
|
||||
<select bind:value={inviteRole} class="bg-transparent border-none text-sm text-gray-700 dark:text-gray-300 px-2 py-2 focus:ring-0 focus:outline-none cursor-pointer font-medium">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="editor">Editor</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="viewer">Viewer</option>
|
||||
</select>
|
||||
<button
|
||||
onclick={copyLink}
|
||||
aria-label="Copy link"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm min-w-[100px] flex items-center justify-center gap-2"
|
||||
type="submit"
|
||||
disabled={inviteStatus === 'loading'}
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors shadow-sm disabled:opacity-70 min-w-[80px]"
|
||||
>
|
||||
{#if copied}
|
||||
<Icon icon="mdi:check" class="text-lg" />
|
||||
<span>Copied!</span>
|
||||
{:else}
|
||||
<Icon icon="mdi:content-copy" class="text-lg" />
|
||||
<span>Copy</span>
|
||||
{/if}
|
||||
{inviteStatus === 'loading' ? 'Inviting...' : 'Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{#if inviteMessage}
|
||||
<div class="flex items-center gap-1.5 text-xs font-medium {inviteStatus === 'success' ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-600 dark:text-red-400'}">
|
||||
<Icon icon={inviteStatus === 'success' ? 'mdi:check-circle' : 'mdi:alert-circle'} class="text-sm" />
|
||||
{inviteMessage}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 pt-2">
|
||||
<label for="general-access-select" class="text-sm font-medium text-gray-700 dark:text-gray-300 block">General Access</label>
|
||||
<div class="flex items-center gap-3 p-3 bg-gray-50 dark:bg-zinc-950/50 rounded-lg border border-gray-200 dark:border-zinc-800">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-gray-600 dark:text-gray-400"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
||||
|
||||
<div class="h-px bg-gray-200 dark:bg-zinc-800/50"></div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="text-sm font-semibold text-[var(--theme-text)]" style="color: var(--theme-text);">General Access</div>
|
||||
<div class="flex items-center gap-4 p-3 bg-gray-50/50 dark:bg-zinc-950/30 rounded-xl border border-gray-200 dark:border-zinc-800/50 hover:bg-gray-50 dark:hover:bg-zinc-900/50 transition-colors">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 p-2.5 rounded-full text-gray-600 dark:text-gray-300">
|
||||
<Icon icon="mdi:earth" class="text-xl" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white">Anyone with the link</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Can view and collaborate</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">Can view and collaborate based on role</p>
|
||||
</div>
|
||||
<select id="general-access-select" bind:value={role} class="bg-transparent text-sm font-medium text-gray-700 dark:text-gray-300 focus:outline-none cursor-pointer">
|
||||
<select bind:value={role} class="bg-gray-100 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 text-sm font-medium text-gray-700 dark:text-gray-300 rounded-md px-3 py-1.5 focus:outline-none cursor-pointer focus:ring-2 focus:ring-blue-500/20 hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors">
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="viewer">Viewer</option>
|
||||
<option class="bg-white dark:bg-zinc-800 text-gray-900 dark:text-gray-100" value="editor">Editor</option>
|
||||
</select>
|
||||
@@ -98,8 +145,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-gray-50 dark:bg-zinc-950/50 border-t border-gray-100 dark:border-zinc-800 flex justify-end">
|
||||
<button onclick={onClose} aria-label="Done" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-900 dark:text-gray-300 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-md transition-colors">
|
||||
<div class="p-4 bg-gray-50 dark:bg-zinc-950/50 border-t border-[var(--theme-border)] flex items-center justify-between" style="border-color: var(--theme-border);">
|
||||
<button
|
||||
onclick={copyLink}
|
||||
class="flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-blue-600 hover:bg-blue-50 dark:text-blue-400 dark:hover:bg-blue-500/10 transition-colors"
|
||||
>
|
||||
{#if copied}
|
||||
<Icon icon="mdi:check" class="text-lg" />
|
||||
<span>Link copied!</span>
|
||||
{:else}
|
||||
<Icon icon="mdi:link-variant" class="text-lg" />
|
||||
<span>Copy link</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button onclick={onClose} class="px-6 py-2 text-sm font-semibold text-white bg-gray-800 hover:bg-gray-900 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white rounded-lg shadow-sm transition-colors">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { exportTypst } from '../ts/typst-api';
|
||||
import { text } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore } from '../ts/store';
|
||||
import { text, undoManager } from '../ts/yjs-setup';
|
||||
import { connectionStatus, connectedUsers, themeStore, darkModeStore, editorViewStore, documentZoomStore, commentsSidebarOpen, versionHistoryOpen } from '../ts/store';
|
||||
import { themes } from '../ts/themes';
|
||||
import { goto } from '$app/navigation';
|
||||
import ShareModal from './ShareModal.svelte';
|
||||
import PageSettingsModal from './PageSettingsModal.svelte';
|
||||
import ThemePicker from './ThemePicker.svelte';
|
||||
import PresentationMode from "./PresentationMode.svelte";
|
||||
import CommentsSidebar from "./CommentsSidebar.svelte";
|
||||
import VersionHistorySidebar from "./VersionHistorySidebar.svelte";
|
||||
import Icon from '@iconify/svelte';
|
||||
|
||||
let isShareModalOpen = $state(false);
|
||||
let isPageSettingsOpen = $state(false);
|
||||
let fileInput: HTMLInputElement;
|
||||
let isPresentationOpen = $state(false);
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
|
||||
let { title = 'Untitled Document', docId = undefined } = $props<{ title?: string, docId?: string }>();
|
||||
let { title = 'Untitled Document', docId = undefined, isViewer = false } = $props<{ title?: string, docId?: string, isViewer?: boolean }>();
|
||||
|
||||
function handleExport(format: 'pdf' | 'png' | 'svg' | 'typ') {
|
||||
if (!text) return;
|
||||
@@ -39,6 +43,54 @@
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaveVersion() {
|
||||
if (!text || !docId) return;
|
||||
const content = text.toString();
|
||||
fetch(`/api/docs/${docId}/versions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content })
|
||||
}).then(res => {
|
||||
if (!res.ok) throw new Error("Failed to save version");
|
||||
alert("Version saved successfully.");
|
||||
}).catch(err => {
|
||||
console.error(err);
|
||||
alert("Failed to save version.");
|
||||
});
|
||||
}
|
||||
|
||||
function handlePandocExport(format: string) {
|
||||
if (!text || !docId) return;
|
||||
const content = text.toString();
|
||||
const safeTitle = title.replace(/[^a-z0-9_-]/gi, "_");
|
||||
fetch(`/api/export/pandoc/${format}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ text: content, document_id: docId })
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error("Export failed");
|
||||
return res.blob();
|
||||
})
|
||||
.then(blob => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
let ext = format;
|
||||
if (format === "latex") ext = "tex";
|
||||
if (format === "markdown") ext = "md";
|
||||
a.download = `${safeTitle}.${ext}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
alert(`Failed to export as ${format}`);
|
||||
});
|
||||
}
|
||||
|
||||
function insertTypstConfig(setting: string, value: string) {
|
||||
if (!text) return;
|
||||
const content = text.toString();
|
||||
@@ -267,7 +319,7 @@
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<header class="flex flex-col border-b border-gray-200 dark:border-white/10 bg-white/80 dark:bg-black/20 backdrop-blur-md select-none w-full relative z-[60]">
|
||||
<header class="flex flex-col border-b border-[var(--theme-border)] bg-[var(--theme-bg)] text-[var(--theme-text)] backdrop-blur-md select-none w-full relative z-[60]" style="background-color: var(--theme-bg); color: var(--theme-text); border-color: var(--theme-border);">
|
||||
|
||||
<div class="flex items-center justify-between px-4 py-2.5">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -307,12 +359,16 @@
|
||||
{#if activeMenu === 'file'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; goto('/dashboard'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">New / Open</button>
|
||||
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Document Info</button>
|
||||
{#if !isViewer}
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; handleSaveVersion(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Save Version</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; openRename(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Rename</button>
|
||||
<button onclick={() => { activeMenu = null; isShareModalOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Share</button>
|
||||
<button onclick={() => { activeMenu = null; openInfo(); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Document Info</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; isPageSettingsOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Page Settings</button>
|
||||
{/if}
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Download</div>
|
||||
<button onclick={() => { activeMenu = null; handleExport('typ'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.typ source</button>
|
||||
@@ -320,11 +376,20 @@
|
||||
<button onclick={() => { activeMenu = null; handleExport('png'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.png image</button>
|
||||
<button onclick={() => { activeMenu = null; handleExport('svg'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">.svg graphics</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<div class="px-4 py-1.5 text-xs font-semibold text-gray-400 uppercase tracking-wider">Export (Pandoc)</div>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('docx'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Word (.docx)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('latex'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">LaTeX (.tex)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('markdown'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Markdown (.md)</button>
|
||||
<button onclick={() => { activeMenu = null; handlePandocExport('html'); }} class="w-full text-left px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">HTML (.html)</button>
|
||||
{#if !isViewer}
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; deleteDoc(); }} class="w-full text-left px-4 py-1.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/10">Delete</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isViewer}
|
||||
<div class="relative">
|
||||
<button
|
||||
onclick={(e) => { e.stopPropagation(); activeMenu = activeMenu === 'edit' ? null : 'edit'; }}
|
||||
@@ -334,8 +399,8 @@
|
||||
</button>
|
||||
{#if activeMenu === 'edit'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('undo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Undo (Ctrl+Z)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('redo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Redo (Ctrl+Y)</button>
|
||||
<button onclick={() => { activeMenu = null; if (undoManager) undoManager.undo(); else document.execCommand('undo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Undo (Ctrl+Z)</button>
|
||||
<button onclick={() => { activeMenu = null; if (undoManager) undoManager.redo(); else document.execCommand('redo'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Redo (Ctrl+Y)</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('cut'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Cut (Ctrl+X)</button>
|
||||
<button onclick={() => { activeMenu = null; document.execCommand('copy'); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5">Copy (Ctrl+C)</button>
|
||||
@@ -343,6 +408,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
@@ -353,6 +419,10 @@
|
||||
</button>
|
||||
{#if activeMenu === 'view'}
|
||||
<div class="absolute left-0 top-full mt-1 w-48 bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-white/10 py-1 z-[100]">
|
||||
<button onclick={() => { activeMenu = null; $versionHistoryOpen = true; }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center justify-between">
|
||||
Version History
|
||||
</button>
|
||||
<div class="h-px bg-gray-100 dark:bg-white/10 my-1"></div>
|
||||
<button onclick={() => { activeMenu = null; $darkModeStore = !$darkModeStore; document.documentElement.classList.toggle('dark', $darkModeStore); }} class="w-full text-left px-4 py-1.5 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-white/5 flex items-center justify-between">
|
||||
Dark Mode
|
||||
<Icon icon={$darkModeStore ? "mdi:check" : ""} class="text-sm" />
|
||||
@@ -393,6 +463,23 @@
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
|
||||
{#if !isViewer}
|
||||
<button
|
||||
onclick={() => (isPresentationOpen = true)}
|
||||
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:presentation-play" class="text-[16px]" />
|
||||
Present
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => ($commentsSidebarOpen = !$commentsSidebarOpen)}
|
||||
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
>
|
||||
<Icon icon="mdi:comment-outline" class="text-[16px]" />
|
||||
Comments
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => (isShareModalOpen = true)}
|
||||
class="flex items-center gap-1.5 px-3.5 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 dark:text-gray-200 dark:bg-black/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
@@ -400,6 +487,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" x2="12" y1="2" y2="15"/></svg>
|
||||
Share
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="w-px h-5 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
@@ -445,9 +533,11 @@
|
||||
</button>
|
||||
<div class="w-px h-4 mx-1 bg-gray-300 dark:bg-white/10"></div>
|
||||
<input type="file" bind:this={fileInput} onchange={handleImageUpload} class="hidden" accept="image/*,.ttf,.otf" />
|
||||
<button onclick={() => fileInput.click()} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Upload Image / Font">
|
||||
{#if !isViewer}
|
||||
<button onclick={() => fileInput?.click()} class="p-1.5 text-gray-600 hover:text-gray-900 hover:bg-gray-200 dark:text-gray-400 dark:hover:text-white dark:hover:bg-white/10 rounded transition-colors" title="Upload Image / Font">
|
||||
<Icon icon="mdi:image-plus" class="text-lg" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
@@ -469,6 +559,7 @@
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !isViewer}
|
||||
<button
|
||||
onclick={() => (isPageSettingsOpen = true)}
|
||||
class="flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold text-gray-600 hover:text-gray-900 bg-white hover:bg-gray-100 border border-gray-300 rounded shadow-sm dark:text-gray-300 dark:bg-black/20 dark:border-white/20 dark:hover:bg-white/10 dark:hover:text-white transition-colors"
|
||||
@@ -476,6 +567,7 @@
|
||||
<Icon icon="mdi:file-document-edit-outline" class="text-sm" />
|
||||
Page Settings
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-px h-4 bg-gray-300 dark:bg-white/10"></div>
|
||||
@@ -520,6 +612,18 @@
|
||||
<PageSettingsModal onClose={() => (isPageSettingsOpen = false)} onApply={handlePageSettings} currentSettings={parseSettings()} />
|
||||
{/if}
|
||||
|
||||
{#if isPresentationOpen}
|
||||
<PresentationMode onClose={() => (isPresentationOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if $commentsSidebarOpen && docId}
|
||||
<CommentsSidebar docId={docId} onClose={() => ($commentsSidebarOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if $versionHistoryOpen && docId}
|
||||
<VersionHistorySidebar docId={docId} onClose={() => ($versionHistoryOpen = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showInfoModal}
|
||||
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" onclick={() => showInfoModal = false} role="presentation" onkeydown={(e) => { if (e.key === "Enter") { showInfoModal = false; } }}>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Icon from '@iconify/svelte';
|
||||
import { text } from '../ts/yjs-setup';
|
||||
|
||||
let { docId, onClose } = $props<{ docId: string, onClose: () => void }>();
|
||||
|
||||
type DocumentVersion = {
|
||||
id: string;
|
||||
document_id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
author_name?: string;
|
||||
};
|
||||
|
||||
let versions = $state<DocumentVersion[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let previewVersion = $state<DocumentVersion | null>(null);
|
||||
|
||||
async function fetchVersions() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/docs/${docId}/versions`);
|
||||
if (!res.ok) throw new Error('Failed to load versions');
|
||||
versions = await res.json();
|
||||
} catch (e: any) {
|
||||
error = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreVersion(version: DocumentVersion) {
|
||||
if (!text) return;
|
||||
if (!confirm('Are you sure you want to restore this version? This will overwrite the current document.')) return;
|
||||
|
||||
const currentLength = text.length;
|
||||
text.delete(0, currentLength);
|
||||
text.insert(0, version.content);
|
||||
previewVersion = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchVersions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="fixed right-0 top-0 bottom-0 w-80 bg-[var(--theme-bg)] backdrop-blur-xl border-l shadow-2xl flex flex-col z-[70] transform transition-transform duration-300 border-[var(--theme-border)]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b bg-gray-50/50 bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<Icon icon="mdi:history" class="text-lg" />
|
||||
<h2 class="text-sm font-semibold text-[var(--theme-text)]">Version History</h2>
|
||||
<span class="text-[10px] font-bold px-2 py-0.5 rounded-full">{versions.length}</span>
|
||||
</div>
|
||||
<button onclick={onClose} class="p-1.5 hover:text-gray-600 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-white/10 rounded-md transition-colors" title="Close Version History">
|
||||
<Icon icon="mdi:close" class="text-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Feed -->
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{#if loading}
|
||||
<div class="flex justify-center items-center h-full">
|
||||
<Icon icon="mdi:loading" class="animate-spin text-2xl" />
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="text-red-500 text-sm text-center p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-900/30">
|
||||
{error}
|
||||
</div>
|
||||
{:else if versions.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full space-y-2">
|
||||
<Icon icon="mdi:history" class="text-4xl opacity-50" />
|
||||
<p class="text-sm">No versions saved yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each versions as version}
|
||||
<div class="group flex flex-col gap-2 p-3 border rounded-xl shadow-sm hover:shadow-md transition-all bg-[var(--theme-bg)] text-[var(--theme-text)] border-[var(--theme-border)]">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-6 h-6 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 flex items-center justify-center text-xs font-bold">
|
||||
{(version.author_name || 'A').substring(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-[var(--theme-text)]">{version.author_name || 'Anonymous'}</p>
|
||||
<p class="text-[10px]">{formatDate(version.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-2">
|
||||
<button onclick={() => previewVersion = version} class="flex-1 px-3 py-1.5 hover:bg-gray-200 dark:hover:bg-white/20 text-xs font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5">
|
||||
<Icon icon="mdi:eye" class="text-sm" />
|
||||
Preview
|
||||
</button>
|
||||
<button onclick={() => restoreVersion(version)} class="flex-1 px-3 py-1.5 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:bg-purple-900/20 dark:text-purple-400 dark:hover:bg-purple-900/40 text-xs font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5">
|
||||
<Icon icon="mdi:restore" class="text-sm" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if previewVersion}
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[100] flex items-center justify-center p-4 transition-opacity" role="presentation" onclick={() => previewVersion = null} onkeydown={(e) => { if (e.key === "Escape") previewVersion = null; }}>
|
||||
<div class="bg-[var(--theme-bg)] backdrop-blur-xl rounded-2xl shadow-2xl border border-[var(--theme-border)] w-full max-w-4xl h-[80vh] flex flex-col transform transition-all" role="dialog" tabindex="-1" aria-modal="true" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--theme-border)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon icon="mdi:eye" class="text-blue-500 text-xl" />
|
||||
<h3 class="text-lg font-semibold text-[var(--theme-text)]">Previewing Version</h3>
|
||||
<span class="text-sm">{formatDate(previewVersion.created_at)}</span>
|
||||
</div>
|
||||
<button onclick={() => previewVersion = null} class="p-1.5 hover:text-gray-600 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-white/10 rounded-md transition-colors" title="Close Preview">
|
||||
<Icon icon="mdi:close" class="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-auto p-6 bg-gray-50/50 bg-[var(--theme-bg)] text-[var(--theme-text)]">
|
||||
<pre class="text-sm font-mono whitespace-pre-wrap word-break-break-word">{previewVersion.content}</pre>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t flex justify-end gap-3 bg-white/50 rounded-b-2xl border-[var(--theme-border)]">
|
||||
<button onclick={() => previewVersion = null} class="px-4 py-2 text-sm font-medium hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-white/10 rounded-lg transition-colors">
|
||||
Close
|
||||
</button>
|
||||
<button onclick={() => restoreVersion(previewVersion!)} class="bg-purple-600 hover:bg-purple-700 px-5 py-2 rounded-lg text-sm font-medium transition-colors shadow-sm flex items-center gap-2">
|
||||
<Icon icon="mdi:restore" class="text-lg" />
|
||||
Restore This Version
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -3,6 +3,7 @@ import { writable } from 'svelte/store';
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export const userStore = writable<User | null>(null);
|
||||
|
||||
@@ -5,6 +5,9 @@ export const darkModeStore = writable(true);
|
||||
export const connectionStatus = writable('connecting');
|
||||
export const editorViewStore = writable<any>(null);
|
||||
export const documentZoomStore = writable(100);
|
||||
export const commentsSidebarOpen = writable(false);
|
||||
export const versionHistoryOpen = writable(false);
|
||||
export const commentReference = writable('');
|
||||
|
||||
export interface AwarenessUser {
|
||||
clientId: number;
|
||||
|
||||
@@ -29,8 +29,8 @@ export const themes: Record<string, ThemeConfig> = {
|
||||
keyword: "#e879f9", string: "#2dd4bf", number: "#fbbf24", comment: "#737373", variable: "#f5f5f5", function: "#818cf8"
|
||||
},
|
||||
light: {
|
||||
background: "#ffffff", text: "#171717", selection: "#f5f5f5", cursor: "#171717",
|
||||
keyword: "#c026d3", string: "#0d9488", number: "#d97706", comment: "#525252", variable: "#171717", function: "#4f46e5"
|
||||
background: "#ffffff", text: "#000000", selection: "#d4d4d4", cursor: "#000000",
|
||||
keyword: "#a21caf", string: "#0f766e", number: "#b45309", comment: "#52525b", variable: "#000000", function: "#4338ca"
|
||||
}
|
||||
},
|
||||
Catppuccin: {
|
||||
@@ -40,8 +40,8 @@ export const themes: Record<string, ThemeConfig> = {
|
||||
keyword: "#cba6f7", string: "#a6e3a1", number: "#fab387", comment: "#6c7086", variable: "#cdd6f4", function: "#89b4fa"
|
||||
},
|
||||
light: {
|
||||
background: "#eff1f5", text: "#4c4f69", selection: "#e6e9ef", cursor: "#dc8a78",
|
||||
keyword: "#8839ef", string: "#40a02b", number: "#fe640b", comment: "#9ca0b0", variable: "#4c4f69", function: "#1e66f5"
|
||||
background: "#eff1f5", text: "#11111b", selection: "#ccd0da", cursor: "#d20f39",
|
||||
keyword: "#5c249a", string: "#327f22", number: "#e64553", comment: "#5c5f77", variable: "#11111b", function: "#1e66f5"
|
||||
}
|
||||
},
|
||||
"Arch Linux": {
|
||||
@@ -51,14 +51,15 @@ export const themes: Record<string, ThemeConfig> = {
|
||||
keyword: "#bc8cff", string: "#3fb950", number: "#ffa657", comment: "#6e7681", variable: "#c9d1d9", function: "#1793d1"
|
||||
},
|
||||
light: {
|
||||
background: "#ffffff", text: "#24292f", selection: "#f6f8fa", cursor: "#24292f",
|
||||
keyword: "#8250df", string: "#1a7f37", number: "#bc4c00", comment: "#6e7781", variable: "#24292f", function: "#1793d1"
|
||||
background: "#ffffff", text: "#0d1117", selection: "#d0d7de", cursor: "#0969da",
|
||||
keyword: "#5a32a3", string: "#1a7f37", number: "#953800", comment: "#57606a", variable: "#0d1117", function: "#0550ae"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function getThemeExtension(themeName: keyof typeof themes, isDark: boolean) {
|
||||
const colors = themes[themeName][isDark ? 'dark' : 'light'];
|
||||
const themeConfig = themes[themeName] || themes['Catppuccin'];
|
||||
const colors = themeConfig[isDark ? 'dark' : 'light'];
|
||||
|
||||
const theme = EditorView.theme({
|
||||
"&": {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AwarenessUser } from './store';
|
||||
export let doc: Y.Doc | null = null;
|
||||
export let text: Y.Text | null = null;
|
||||
export let provider: WebsocketProvider | null = null;
|
||||
export let undoManager: Y.UndoManager | null = null;
|
||||
|
||||
const userColors = [
|
||||
'#30bced', '#6eeb83', '#ffbc42', '#ecd444', '#ee6352',
|
||||
@@ -25,6 +26,7 @@ export function initYjs(docId: string) {
|
||||
|
||||
doc = new Y.Doc();
|
||||
text = doc.getText('typst');
|
||||
undoManager = new Y.UndoManager(text);
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
@@ -89,6 +91,10 @@ export function cleanupYjs() {
|
||||
provider.disconnect();
|
||||
provider = null;
|
||||
}
|
||||
if (undoManager) {
|
||||
undoManager.destroy();
|
||||
undoManager = null;
|
||||
}
|
||||
doc = null;
|
||||
text = null;
|
||||
connectionStatus.set('disconnected');
|
||||
|
||||
Reference in New Issue
Block a user