enh: note editor

This commit is contained in:
Timothy Jaeryang Baek 2025-05-04 18:32:21 +04:00
parent d8e9b000e3
commit 360b5b303a

View File

@ -1,6 +1,11 @@
<script lang="ts"> <script lang="ts">
import { getContext, onDestroy, onMount, tick } from 'svelte'; import { getContext, onDestroy, onMount, tick } from 'svelte';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import jsPDF from 'jspdf';
import html2canvas from 'html2canvas-pro';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@ -10,6 +15,8 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { compressImage } from '$lib/utils'; import { compressImage } from '$lib/utils';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { uploadFile } from '$lib/apis/files';
import dayjs from '$lib/dayjs'; import dayjs from '$lib/dayjs';
import calendar from 'dayjs/plugin/calendar'; import calendar from 'dayjs/plugin/calendar';
@ -34,22 +41,23 @@
// Assuming $i18n.languages is an array of language codes // Assuming $i18n.languages is an array of language codes
$: loadLocale($i18n.languages); $: loadLocale($i18n.languages);
import { getNoteById, updateNoteById } from '$lib/apis/notes'; import { deleteNoteById, getNoteById, updateNoteById } from '$lib/apis/notes';
import RichTextInput from '../common/RichTextInput.svelte'; import RichTextInput from '../common/RichTextInput.svelte';
import Spinner from '../common/Spinner.svelte'; import Spinner from '../common/Spinner.svelte';
import MicSolid from '../icons/MicSolid.svelte'; import MicSolid from '../icons/MicSolid.svelte';
import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte'; import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
import Tooltip from '../common/Tooltip.svelte'; import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import Calendar from '../icons/Calendar.svelte'; import Calendar from '../icons/Calendar.svelte';
import Users from '../icons/Users.svelte'; import Users from '../icons/Users.svelte';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { uploadFile } from '$lib/apis/files';
import Image from '../common/Image.svelte'; import Image from '../common/Image.svelte';
import FileItem from '../common/FileItem.svelte'; import FileItem from '../common/FileItem.svelte';
import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte'; import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
import RecordMenu from './RecordMenu.svelte'; import RecordMenu from './RecordMenu.svelte';
import NoteMenu from './Notes/NoteMenu.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
export let id: null | string = null; export let id: null | string = null;
@ -70,9 +78,11 @@
}; };
let files = []; let files = [];
let recording = false; let recording = false;
let displayMediaRecord = false; let displayMediaRecord = false;
let showDeleteConfirm = false;
let dragged = false; let dragged = false;
let loading = false; let loading = false;
@ -245,6 +255,94 @@
}); });
}; };
const downloadHandler = async (type) => {
console.log('downloadHandler', type);
if (type === 'md') {
const blob = new Blob([note.data.content.md], { type: 'text/markdown' });
saveAs(blob, `${note.title}.md`);
} else if (type === 'pdf') {
await downloadPdf(note);
}
};
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)
// STEP 1. Get a DOM node to render
const html = note.data?.content?.html ?? '';
let node;
if (html instanceof HTMLElement) {
node = html;
} else {
// If it's HTML string, render to a temporary hidden element
node = document.createElement('div');
node.innerHTML = html;
document.body.appendChild(node);
}
// Render to canvas with predefined width
const canvas = await html2canvas(node, {
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
});
// Remove hidden node if needed
if (!(html instanceof HTMLElement)) {
document.body.removeChild(node);
}
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);
toast.error(`${error}`);
}
};
const deleteNoteHandler = async (id) => {
const res = await deleteNoteById(localStorage.token, id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Note deleted successfully'));
goto('/notes');
} else {
toast.error($i18n.t('Failed to delete note'));
}
};
const onDragOver = (e) => { const onDragOver = (e) => {
e.preventDefault(); e.preventDefault();
@ -299,6 +397,19 @@
<FilesOverlay show={dragged} /> <FilesOverlay show={dragged} />
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete note?')}
on:confirm={() => {
deleteNoteHandler(note.id);
showDeleteConfirm = false;
}}
>
<div class=" text-sm text-gray-500">
{$i18n.t('This will delete')} <span class=" font-semibold">{note.title}</span>.
</div>
</DeleteConfirmDialog>
<div class="relative flex-1 w-full h-full flex justify-center" id="note-editor"> <div class="relative flex-1 w-full h-full flex justify-center" id="note-editor">
{#if loading} {#if loading}
<div class=" absolute top-0 bottom-0 left-0 right-0 flex"> <div class=" absolute top-0 bottom-0 left-0 right-0 flex">
@ -309,7 +420,7 @@
{:else} {:else}
<div class=" w-full flex flex-col {loading ? 'opacity-20' : ''}"> <div class=" w-full flex flex-col {loading ? 'opacity-20' : ''}">
<div class="shrink-0 w-full flex justify-between items-center px-4.5 pt-1 mb-1.5"> <div class="shrink-0 w-full flex justify-between items-center px-4.5 pt-1 mb-1.5">
<div class="w-full"> <div class="w-full flex">
<input <input
class="w-full text-2xl font-medium bg-transparent outline-hidden" class="w-full text-2xl font-medium bg-transparent outline-hidden"
type="text" type="text"
@ -317,6 +428,24 @@
placeholder={$i18n.t('Title')} placeholder={$i18n.t('Title')}
required required
/> />
<div>
<NoteMenu
onDownload={(type) => {
downloadHandler(type);
}}
onDelete={() => {
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>
</div> </div>