Timothy Jaeryang Baek 5 ماه پیش
والد
کامیت
175bbe27e9

+ 2 - 2
src/lib/components/notes/NoteEditor.svelte

@@ -371,8 +371,8 @@
 					placeholder={$i18n.t('Write something...')}
 					json={true}
 					onChange={(content) => {
-						note.data.html = content.html;
-						note.data.md = content.md;
+						note.data.content.html = content.html;
+						note.data.content.md = content.md;
 					}}
 				/>
 			</div>

+ 85 - 4
src/lib/components/notes/Notes.svelte

@@ -3,6 +3,9 @@
 	import fileSaver from 'file-saver';
 	const { saveAs } = fileSaver;
 
+	import jsPDF from 'jspdf';
+	import html2canvas from 'html2canvas-pro';
+
 	import dayjs from '$lib/dayjs';
 	import duration from 'dayjs/plugin/duration';
 	import relativeTime from 'dayjs/plugin/relativeTime';
@@ -29,6 +32,7 @@
 	import { WEBUI_NAME, config, prompts as _prompts, user } from '$lib/stores';
 
 	import { createNewNote, getNotes } from '$lib/apis/notes';
+	import { capitalizeFirstLetter } from '$lib/utils';
 
 	import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
 	import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
@@ -37,7 +41,7 @@
 	import ChevronRight from '../icons/ChevronRight.svelte';
 	import Spinner from '../common/Spinner.svelte';
 	import Tooltip from '../common/Tooltip.svelte';
-	import { capitalizeFirstLetter } from '$lib/utils';
+	import NoteMenu from './Notes/NoteMenu.svelte';
 
 	const i18n = getContext('i18n');
 	let loaded = false;
@@ -76,6 +80,62 @@
 		}
 	};
 
+	const downloadHandler = async (type) => {
+		console.log('downloadHandler', type);
+		console.log('selectedNote', selectedNote);
+		if (type === 'md') {
+			const blob = new Blob([selectedNote.data.content.md], { type: 'text/markdown' });
+			saveAs(blob, `${selectedNote.title}.md`);
+		} else if (type === 'pdf') {
+			await downloadPdf(selectedNote);
+		}
+	};
+
+	const downloadPdf = async (note) => {
+		try {
+			// Define a fixed virtual screen size
+			const virtualWidth = 1024; // Fixed width (adjust as needed)
+			const virtualHeight = 1400; // Fixed height (adjust as needed)
+
+			// Render to canvas with predefined width
+			const canvas = await html2canvas(note.data.content.html, {
+				useCORS: true,
+				scale: 2, // Keep at 1x to avoid unexpected enlargements
+				width: virtualWidth, // Set fixed virtual screen width
+				windowWidth: virtualWidth, // Ensure consistent rendering
+				windowHeight: virtualHeight
+			});
+
+			const imgData = canvas.toDataURL('image/png');
+
+			// A4 page settings
+			const pdf = new jsPDF('p', 'mm', 'a4');
+			const imgWidth = 210; // A4 width in mm
+			const pageHeight = 297; // A4 height in mm
+
+			// Maintain aspect ratio
+			const imgHeight = (canvas.height * imgWidth) / canvas.width;
+			let heightLeft = imgHeight;
+			let position = 0;
+
+			pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
+			heightLeft -= pageHeight;
+
+			// Handle additional pages
+			while (heightLeft > 0) {
+				position -= pageHeight;
+				pdf.addPage();
+
+				pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
+				heightLeft -= pageHeight;
+			}
+
+			pdf.save(`${note.title}.pdf`);
+		} catch (error) {
+			console.error('Error generating PDF', error);
+		}
+	};
+
 	onMount(async () => {
 		await init();
 		loaded = true;
@@ -117,15 +177,36 @@
 									class="w-full -translate-y-0.5 flex flex-col justify-between"
 								>
 									<div class="flex-1">
-										<div class="  flex items-center gap-2 self-center mb-1">
+										<div class="  flex items-center gap-2 self-center mb-1 justify-between">
 											<div class=" font-semibold line-clamp-1 capitalize">{note.title}</div>
+
+											<div>
+												<NoteMenu
+													onDownload={(type) => {
+														selectedNote = note;
+
+														downloadHandler(type);
+													}}
+													onDelete={() => {
+														selectedNote = note;
+														showDeleteConfirm = true;
+													}}
+												>
+													<button
+														class="self-center w-fit text-sm p-1 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
+														type="button"
+													>
+														<EllipsisHorizontal className="size-5" />
+													</button>
+												</NoteMenu>
+											</div>
 										</div>
 
 										<div
 											class=" text-xs text-gray-500 dark:text-gray-500 mb-3 line-clamp-5 min-h-18"
 										>
-											{#if note.data?.md}
-												{note.data?.md}
+											{#if note.data?.content?.md}
+												{note.data?.content?.md}
 											{:else}
 												{$i18n.t('No content')}
 											{/if}

+ 87 - 0
src/lib/components/notes/Notes/NoteMenu.svelte

@@ -0,0 +1,87 @@
+<script lang="ts">
+	import { DropdownMenu } from 'bits-ui';
+	import { getContext, onMount } from 'svelte';
+
+	import { flyAndScale } from '$lib/utils/transitions';
+	import { fade, slide } from 'svelte/transition';
+
+	import { showSettings, activeUserIds, USAGE_POOL, mobile, showSidebar, user } from '$lib/stores';
+
+	import Tooltip from '$lib/components/common/Tooltip.svelte';
+	import ArchiveBox from '$lib/components/icons/ArchiveBox.svelte';
+	import Download from '$lib/components/icons/Download.svelte';
+	import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
+
+	const i18n = getContext('i18n');
+
+	export let show = false;
+	export let className = 'max-w-[160px]';
+
+	export let onDownload = (type) => {};
+	export let onDelete = () => {};
+
+	export let onChange = () => {};
+</script>
+
+<DropdownMenu.Root
+	bind:open={show}
+	onOpenChange={(state) => {
+		onChange(state);
+	}}
+>
+	<DropdownMenu.Trigger>
+		<slot />
+	</DropdownMenu.Trigger>
+
+	<slot name="content">
+		<DropdownMenu.Content
+			class="w-full {className} text-sm rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg font-primary"
+			sideOffset={6}
+			side="bottom"
+			align="start"
+			transition={(e) => fade(e, { duration: 100 })}
+		>
+			<DropdownMenu.Sub>
+				<DropdownMenu.SubTrigger
+					class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				>
+					<Download strokeWidth="2" />
+
+					<div class="flex items-center">{$i18n.t('Download')}</div>
+				</DropdownMenu.SubTrigger>
+				<DropdownMenu.SubContent
+					class="w-full rounded-xl px-1 py-1.5 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
+					transition={flyAndScale}
+					sideOffset={8}
+				>
+					<DropdownMenu.Item
+						class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+						on:click={() => {
+							onDownload('md');
+						}}
+					>
+						<div class="flex items-center line-clamp-1">{$i18n.t('Plain text (.md)')}</div>
+					</DropdownMenu.Item>
+
+					<DropdownMenu.Item
+						class="flex gap-2 items-center px-3 py-2 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+						on:click={() => {
+							onDownload('pdf');
+						}}
+					>
+						<div class="flex items-center line-clamp-1">{$i18n.t('PDF document (.pdf)')}</div>
+					</DropdownMenu.Item>
+				</DropdownMenu.SubContent>
+			</DropdownMenu.Sub>
+			<DropdownMenu.Item
+				class="flex  gap-2  items-center px-3 py-1.5 text-sm  cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-md"
+				on:click={() => {
+					onDelete();
+				}}
+			>
+				<GarbageBin strokeWidth="2" />
+				<div class="flex items-center">{$i18n.t('Delete')}</div>
+			</DropdownMenu.Item>
+		</DropdownMenu.Content>
+	</slot>
+</DropdownMenu.Root>