NoteEditor.svelte 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. <script lang="ts">
  2. import { getContext, onDestroy, onMount, tick } from 'svelte';
  3. import { v4 as uuidv4 } from 'uuid';
  4. import fileSaver from 'file-saver';
  5. const { saveAs } = fileSaver;
  6. import jsPDF from 'jspdf';
  7. import html2canvas from 'html2canvas-pro';
  8. const i18n = getContext('i18n');
  9. import { toast } from 'svelte-sonner';
  10. import { config, settings, showSidebar } from '$lib/stores';
  11. import { goto } from '$app/navigation';
  12. import { compressImage } from '$lib/utils';
  13. import { WEBUI_API_BASE_URL } from '$lib/constants';
  14. import { uploadFile } from '$lib/apis/files';
  15. import dayjs from '$lib/dayjs';
  16. import calendar from 'dayjs/plugin/calendar';
  17. import duration from 'dayjs/plugin/duration';
  18. import relativeTime from 'dayjs/plugin/relativeTime';
  19. dayjs.extend(calendar);
  20. dayjs.extend(duration);
  21. dayjs.extend(relativeTime);
  22. async function loadLocale(locales) {
  23. for (const locale of locales) {
  24. try {
  25. dayjs.locale(locale);
  26. break; // Stop after successfully loading the first available locale
  27. } catch (error) {
  28. console.error(`Could not load locale '${locale}':`, error);
  29. }
  30. }
  31. }
  32. // Assuming $i18n.languages is an array of language codes
  33. $: loadLocale($i18n.languages);
  34. import { deleteNoteById, getNoteById, updateNoteById } from '$lib/apis/notes';
  35. import RichTextInput from '../common/RichTextInput.svelte';
  36. import Spinner from '../common/Spinner.svelte';
  37. import MicSolid from '../icons/MicSolid.svelte';
  38. import VoiceRecording from '../chat/MessageInput/VoiceRecording.svelte';
  39. import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
  40. import Calendar from '../icons/Calendar.svelte';
  41. import Users from '../icons/Users.svelte';
  42. import Image from '../common/Image.svelte';
  43. import FileItem from '../common/FileItem.svelte';
  44. import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
  45. import RecordMenu from './RecordMenu.svelte';
  46. import NoteMenu from './Notes/NoteMenu.svelte';
  47. import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
  48. export let id: null | string = null;
  49. let note = null;
  50. const newNote = {
  51. title: '',
  52. data: {
  53. content: {
  54. json: null,
  55. html: '',
  56. md: ''
  57. },
  58. files: null
  59. },
  60. meta: null,
  61. access_control: null
  62. };
  63. let files = [];
  64. let recording = false;
  65. let displayMediaRecord = false;
  66. let showDeleteConfirm = false;
  67. let dragged = false;
  68. let loading = false;
  69. const init = async () => {
  70. loading = true;
  71. const res = await getNoteById(localStorage.token, id).catch((error) => {
  72. toast.error(`${error}`);
  73. return null;
  74. });
  75. if (res) {
  76. note = res;
  77. files = res.data.files || [];
  78. } else {
  79. goto('/');
  80. return;
  81. }
  82. loading = false;
  83. };
  84. let debounceTimeout: NodeJS.Timeout | null = null;
  85. const changeDebounceHandler = () => {
  86. if (debounceTimeout) {
  87. clearTimeout(debounceTimeout);
  88. }
  89. debounceTimeout = setTimeout(async () => {
  90. if (!note) {
  91. return;
  92. }
  93. console.log('Saving note:', note);
  94. const res = await updateNoteById(localStorage.token, id, {
  95. ...note,
  96. title: note.title === '' ? $i18n.t('Untitled') : note.title
  97. }).catch((e) => {
  98. toast.error(`${e}`);
  99. });
  100. }, 200);
  101. };
  102. $: if (note) {
  103. changeDebounceHandler();
  104. }
  105. $: if (id) {
  106. init();
  107. }
  108. const uploadFileHandler = async (file) => {
  109. const tempItemId = uuidv4();
  110. const fileItem = {
  111. type: 'file',
  112. file: '',
  113. id: null,
  114. url: '',
  115. name: file.name,
  116. collection_name: '',
  117. status: 'uploading',
  118. size: file.size,
  119. error: '',
  120. itemId: tempItemId
  121. };
  122. if (fileItem.size == 0) {
  123. toast.error($i18n.t('You cannot upload an empty file.'));
  124. return null;
  125. }
  126. files = [...files, fileItem];
  127. try {
  128. // During the file upload, file content is automatically extracted.
  129. const uploadedFile = await uploadFile(localStorage.token, file);
  130. if (uploadedFile) {
  131. console.log('File upload completed:', {
  132. id: uploadedFile.id,
  133. name: fileItem.name,
  134. collection: uploadedFile?.meta?.collection_name
  135. });
  136. if (uploadedFile.error) {
  137. console.warn('File upload warning:', uploadedFile.error);
  138. toast.warning(uploadedFile.error);
  139. }
  140. fileItem.status = 'uploaded';
  141. fileItem.file = uploadedFile;
  142. fileItem.id = uploadedFile.id;
  143. fileItem.collection_name =
  144. uploadedFile?.meta?.collection_name || uploadedFile?.collection_name;
  145. fileItem.url = `${WEBUI_API_BASE_URL}/files/${uploadedFile.id}`;
  146. files = files;
  147. } else {
  148. files = files.filter((item) => item?.itemId !== tempItemId);
  149. }
  150. } catch (e) {
  151. toast.error(`${e}`);
  152. files = files.filter((item) => item?.itemId !== tempItemId);
  153. }
  154. if (files.length > 0) {
  155. note.data.files = files;
  156. } else {
  157. note.data.files = null;
  158. }
  159. };
  160. const inputFilesHandler = async (inputFiles) => {
  161. console.log('Input files handler called with:', inputFiles);
  162. inputFiles.forEach((file) => {
  163. console.log('Processing file:', {
  164. name: file.name,
  165. type: file.type,
  166. size: file.size,
  167. extension: file.name.split('.').at(-1)
  168. });
  169. if (
  170. ($config?.file?.max_size ?? null) !== null &&
  171. file.size > ($config?.file?.max_size ?? 0) * 1024 * 1024
  172. ) {
  173. console.log('File exceeds max size limit:', {
  174. fileSize: file.size,
  175. maxSize: ($config?.file?.max_size ?? 0) * 1024 * 1024
  176. });
  177. toast.error(
  178. $i18n.t(`File size should not exceed {{maxSize}} MB.`, {
  179. maxSize: $config?.file?.max_size
  180. })
  181. );
  182. return;
  183. }
  184. if (
  185. ['image/gif', 'image/webp', 'image/jpeg', 'image/png', 'image/avif'].includes(file['type'])
  186. ) {
  187. let reader = new FileReader();
  188. reader.onload = async (event) => {
  189. let imageUrl = event.target.result;
  190. if ($settings?.imageCompression ?? false) {
  191. const width = $settings?.imageCompressionSize?.width ?? null;
  192. const height = $settings?.imageCompressionSize?.height ?? null;
  193. if (width || height) {
  194. imageUrl = await compressImage(imageUrl, width, height);
  195. }
  196. }
  197. files = [
  198. ...files,
  199. {
  200. type: 'image',
  201. url: `${imageUrl}`
  202. }
  203. ];
  204. note.data.files = files;
  205. };
  206. reader.readAsDataURL(file);
  207. } else {
  208. uploadFileHandler(file);
  209. }
  210. });
  211. };
  212. const downloadHandler = async (type) => {
  213. console.log('downloadHandler', type);
  214. if (type === 'md') {
  215. const blob = new Blob([note.data.content.md], { type: 'text/markdown' });
  216. saveAs(blob, `${note.title}.md`);
  217. } else if (type === 'pdf') {
  218. await downloadPdf(note);
  219. }
  220. };
  221. const downloadPdf = async (note) => {
  222. try {
  223. // Define a fixed virtual screen size
  224. const virtualWidth = 1024; // Fixed width (adjust as needed)
  225. const virtualHeight = 1400; // Fixed height (adjust as needed)
  226. // STEP 1. Get a DOM node to render
  227. const html = note.data?.content?.html ?? '';
  228. let node;
  229. if (html instanceof HTMLElement) {
  230. node = html;
  231. } else {
  232. // If it's HTML string, render to a temporary hidden element
  233. node = document.createElement('div');
  234. node.innerHTML = html;
  235. document.body.appendChild(node);
  236. }
  237. // Render to canvas with predefined width
  238. const canvas = await html2canvas(node, {
  239. useCORS: true,
  240. scale: 2, // Keep at 1x to avoid unexpected enlargements
  241. width: virtualWidth, // Set fixed virtual screen width
  242. windowWidth: virtualWidth, // Ensure consistent rendering
  243. windowHeight: virtualHeight
  244. });
  245. // Remove hidden node if needed
  246. if (!(html instanceof HTMLElement)) {
  247. document.body.removeChild(node);
  248. }
  249. const imgData = canvas.toDataURL('image/png');
  250. // A4 page settings
  251. const pdf = new jsPDF('p', 'mm', 'a4');
  252. const imgWidth = 210; // A4 width in mm
  253. const pageHeight = 297; // A4 height in mm
  254. // Maintain aspect ratio
  255. const imgHeight = (canvas.height * imgWidth) / canvas.width;
  256. let heightLeft = imgHeight;
  257. let position = 0;
  258. pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
  259. heightLeft -= pageHeight;
  260. // Handle additional pages
  261. while (heightLeft > 0) {
  262. position -= pageHeight;
  263. pdf.addPage();
  264. pdf.addImage(imgData, 'PNG', 0, position, imgWidth, imgHeight);
  265. heightLeft -= pageHeight;
  266. }
  267. pdf.save(`${note.title}.pdf`);
  268. } catch (error) {
  269. console.error('Error generating PDF', error);
  270. toast.error(`${error}`);
  271. }
  272. };
  273. const deleteNoteHandler = async (id) => {
  274. const res = await deleteNoteById(localStorage.token, id).catch((error) => {
  275. toast.error(`${error}`);
  276. return null;
  277. });
  278. if (res) {
  279. toast.success($i18n.t('Note deleted successfully'));
  280. goto('/notes');
  281. } else {
  282. toast.error($i18n.t('Failed to delete note'));
  283. }
  284. };
  285. const onDragOver = (e) => {
  286. e.preventDefault();
  287. // Check if a file is being dragged.
  288. if (e.dataTransfer?.types?.includes('Files')) {
  289. dragged = true;
  290. } else {
  291. dragged = false;
  292. }
  293. };
  294. const onDragLeave = () => {
  295. dragged = false;
  296. };
  297. const onDrop = async (e) => {
  298. e.preventDefault();
  299. console.log(e);
  300. if (e.dataTransfer?.files) {
  301. const inputFiles = Array.from(e.dataTransfer?.files);
  302. if (inputFiles && inputFiles.length > 0) {
  303. console.log(inputFiles);
  304. inputFilesHandler(inputFiles);
  305. }
  306. }
  307. dragged = false;
  308. };
  309. onMount(async () => {
  310. await tick();
  311. const dropzoneElement = document.getElementById('note-editor');
  312. dropzoneElement?.addEventListener('dragover', onDragOver);
  313. dropzoneElement?.addEventListener('drop', onDrop);
  314. dropzoneElement?.addEventListener('dragleave', onDragLeave);
  315. });
  316. onDestroy(() => {
  317. console.log('destroy');
  318. const dropzoneElement = document.getElementById('note-editor');
  319. if (dropzoneElement) {
  320. dropzoneElement?.removeEventListener('dragover', onDragOver);
  321. dropzoneElement?.removeEventListener('drop', onDrop);
  322. dropzoneElement?.removeEventListener('dragleave', onDragLeave);
  323. }
  324. });
  325. </script>
  326. <FilesOverlay show={dragged} />
  327. <DeleteConfirmDialog
  328. bind:show={showDeleteConfirm}
  329. title={$i18n.t('Delete note?')}
  330. on:confirm={() => {
  331. deleteNoteHandler(note.id);
  332. showDeleteConfirm = false;
  333. }}
  334. >
  335. <div class=" text-sm text-gray-500">
  336. {$i18n.t('This will delete')} <span class=" font-semibold">{note.title}</span>.
  337. </div>
  338. </DeleteConfirmDialog>
  339. <div class="relative flex-1 w-full h-full flex justify-center" id="note-editor">
  340. {#if loading}
  341. <div class=" absolute top-0 bottom-0 left-0 right-0 flex">
  342. <div class="m-auto">
  343. <Spinner />
  344. </div>
  345. </div>
  346. {:else}
  347. <div class=" w-full flex flex-col {loading ? 'opacity-20' : ''}">
  348. <div class="shrink-0 w-full flex justify-between items-center px-4.5 pt-1 mb-1.5">
  349. <div class="w-full flex">
  350. <input
  351. class="w-full text-2xl font-medium bg-transparent outline-hidden"
  352. type="text"
  353. bind:value={note.title}
  354. placeholder={$i18n.t('Title')}
  355. required
  356. />
  357. <div>
  358. <NoteMenu
  359. onDownload={(type) => {
  360. downloadHandler(type);
  361. }}
  362. onDelete={() => {
  363. showDeleteConfirm = true;
  364. }}
  365. >
  366. <button
  367. 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"
  368. type="button"
  369. >
  370. <EllipsisHorizontal className="size-5" />
  371. </button>
  372. </NoteMenu>
  373. </div>
  374. </div>
  375. </div>
  376. <div class=" mb-2.5 px-3.5">
  377. <div class="flex gap-1 items-center text-xs font-medium text-gray-500 dark:text-gray-500">
  378. <button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
  379. <Calendar className="size-3.5" strokeWidth="2" />
  380. <span>{dayjs(note.created_at / 1000000).calendar()}</span>
  381. </button>
  382. <button class=" flex items-center gap-1 w-fit py-1 px-1.5 rounded-lg">
  383. <Users className="size-3.5" strokeWidth="2" />
  384. <span> You </span>
  385. </button>
  386. </div>
  387. </div>
  388. <div class=" flex-1 w-full h-full overflow-auto px-4 pb-5">
  389. {#if files && files.length > 0}
  390. <div class="mb-3.5 mt-1.5 w-full flex gap-1 flex-wrap z-40">
  391. {#each files as file, fileIdx}
  392. <div class="w-fit">
  393. {#if file.type === 'image'}
  394. <Image
  395. src={file.url}
  396. imageClassName=" max-h-96 rounded-lg"
  397. dismissible={true}
  398. onDismiss={() => {
  399. files = files.filter((item, idx) => idx !== fileIdx);
  400. note.data.files = files.length > 0 ? files : null;
  401. }}
  402. />
  403. {:else}
  404. <FileItem
  405. item={file}
  406. dismissible={true}
  407. url={file.url}
  408. name={file.name}
  409. type={file.type}
  410. size={file?.size}
  411. loading={file.status === 'uploading'}
  412. on:dismiss={() => {
  413. files = files.filter((item) => item?.id !== file.id);
  414. note.data.files = files.length > 0 ? files : null;
  415. }}
  416. />
  417. {/if}
  418. </div>
  419. {/each}
  420. </div>
  421. {/if}
  422. <RichTextInput
  423. className="input-prose-sm px-0.5"
  424. bind:value={note.data.content.json}
  425. placeholder={$i18n.t('Write something...')}
  426. json={true}
  427. onChange={(content) => {
  428. note.data.content.html = content.html;
  429. note.data.content.md = content.md;
  430. }}
  431. />
  432. </div>
  433. </div>
  434. {/if}
  435. </div>
  436. <div class="absolute bottom-0 right-0 p-5 max-w-full flex justify-end">
  437. <div
  438. class="flex gap-0.5 justify-end w-full {$showSidebar && recording
  439. ? 'md:max-w-[calc(100%-260px)]'
  440. : ''} max-w-full"
  441. >
  442. {#if recording}
  443. <div class="flex-1 w-full">
  444. <VoiceRecording
  445. bind:recording
  446. className="p-1 w-full max-w-full"
  447. transcribe={false}
  448. displayMedia={displayMediaRecord}
  449. onCancel={() => {
  450. recording = false;
  451. displayMediaRecord = false;
  452. }}
  453. onConfirm={(data) => {
  454. if (data?.file) {
  455. uploadFileHandler(data?.file);
  456. }
  457. recording = false;
  458. displayMediaRecord = false;
  459. }}
  460. />
  461. </div>
  462. {:else}
  463. <RecordMenu
  464. onRecord={async () => {
  465. displayMediaRecord = false;
  466. try {
  467. let stream = await navigator.mediaDevices
  468. .getUserMedia({ audio: true })
  469. .catch(function (err) {
  470. toast.error(
  471. $i18n.t(`Permission denied when accessing microphone: {{error}}`, {
  472. error: err
  473. })
  474. );
  475. return null;
  476. });
  477. if (stream) {
  478. recording = true;
  479. const tracks = stream.getTracks();
  480. tracks.forEach((track) => track.stop());
  481. }
  482. stream = null;
  483. } catch {
  484. toast.error($i18n.t('Permission denied when accessing microphone'));
  485. }
  486. }}
  487. onCaptureAudio={async () => {
  488. displayMediaRecord = true;
  489. recording = true;
  490. }}
  491. onUpload={async () => {
  492. const input = document.createElement('input');
  493. input.type = 'file';
  494. input.accept = 'audio/*';
  495. input.multiple = false;
  496. input.click();
  497. input.onchange = async (e) => {
  498. const files = e.target.files;
  499. if (files && files.length > 0) {
  500. await uploadFileHandler(files[0]);
  501. }
  502. };
  503. }}
  504. >
  505. <button
  506. class="cursor-pointer p-2.5 flex rounded-full border border-gray-50 dark:border-none dark:bg-gray-850 hover:bg-gray-50 dark:hover:bg-gray-800 transition shadow-xl"
  507. type="button"
  508. >
  509. <MicSolid className="size-4.5" />
  510. </button>
  511. </RecordMenu>
  512. {/if}
  513. </div>
  514. </div>