diff --git a/apps/client/src/services/content_renderer.ts b/apps/client/src/services/content_renderer.ts index afd53c53f9..ec29f094b5 100644 --- a/apps/client/src/services/content_renderer.ts +++ b/apps/client/src/services/content_renderer.ts @@ -54,7 +54,7 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo await renderText(entity, $renderedContent, options); } else if (type === "code") { await renderCode(entity, $renderedContent); - } else if (["image", "canvas", "mindMap"].includes(type)) { + } else if (["image", "canvas", "mindMap", "spreadsheet"].includes(type)) { renderImage(entity, $renderedContent, options); } else if (!options.tooltip && ["file", "pdf", "audio", "video"].includes(type)) { await renderFile(entity, type, $renderedContent); diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index fb1e598ec2..627e28622a 100644 --- a/apps/client/src/services/server.ts +++ b/apps/client/src/services/server.ts @@ -89,7 +89,7 @@ async function remove(url: string, componentId?: string) { return await call("DELETE", url, componentId); } -async function upload(url: string, fileToUpload: File, componentId?: string) { +async function upload(url: string, fileToUpload: File, componentId?: string, method = "PUT") { const formData = new FormData(); formData.append("upload", fileToUpload); @@ -99,7 +99,7 @@ async function upload(url: string, fileToUpload: File, componentId?: string) { "trilium-component-id": componentId } : undefined), data: formData, - type: "PUT", + type: method, timeout: 60 * 60 * 1000, contentType: false, // NEEDED, DON'T REMOVE THIS processData: false // NEEDED, DON'T REMOVE THIS diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 7616b9d9b1..46d83a5614 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -98,6 +98,7 @@ export interface SavedData { mime: string; content: string; position: number; + encoding?: "base64"; }[]; } diff --git a/apps/client/src/widgets/type_widgets/Spreadsheet.tsx b/apps/client/src/widgets/type_widgets/Spreadsheet.tsx index a4f7389ebb..2062cec760 100644 --- a/apps/client/src/widgets/type_widgets/Spreadsheet.tsx +++ b/apps/client/src/widgets/type_widgets/Spreadsheet.tsx @@ -8,7 +8,8 @@ import { MutableRef, useEffect, useRef } from "preact/hooks"; import NoteContext from "../../components/note_context"; import FNote from "../../entities/fnote"; -import { useColorScheme, useEditorSpacedUpdate, useTriliumEvent } from "../react/hooks"; +import server from "../../services/server"; +import { SavedData, useColorScheme, useEditorSpacedUpdate, useTriliumEvent } from "../react/hooks"; import { TypeWidgetProps } from "./type_widget"; interface PersistedData { @@ -22,7 +23,7 @@ export default function Spreadsheet({ note, noteContext }: TypeWidgetProps) { useInitializeSpreadsheet(containerRef, apiRef); useDarkMode(apiRef); - usePersistence(note, noteContext, apiRef); + usePersistence(note, noteContext, apiRef, containerRef); // Focus the spreadsheet when the note is focused. useTriliumEvent("focusOnDetail", () => { @@ -68,14 +69,14 @@ function useDarkMode(apiRef: MutableRef) { }, [ colorScheme, apiRef ]); } -function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef) { +function usePersistence(note: FNote, noteContext: NoteContext | null | undefined, apiRef: MutableRef, containerRef: MutableRef) { const changeListener = useRef(null); const spacedUpdate = useEditorSpacedUpdate({ noteType: "spreadsheet", note, noteContext, - getData() { + async getData() { const univerAPI = apiRef.current; if (!univerAPI) return undefined; const workbook = univerAPI.getActiveWorkbook(); @@ -84,8 +85,25 @@ function usePersistence(note: FNote, noteContext: NoteContext | null | undefined version: 1, workbook: workbook.save() }; + + const attachments: SavedData["attachments"] = []; + const canvasEl = containerRef.current?.querySelector("canvas[id]"); + if (canvasEl) { + const dataUrl = canvasEl.toDataURL("image/png"); + const base64 = dataUrl.split(",")[1]; + attachments.push({ + role: "image", + title: "spreadsheet-export.png", + mime: "image/png", + content: base64, + position: 0, + encoding: "base64" + }); + } + return { - content: JSON.stringify(content) + content: JSON.stringify(content), + attachments }; }, onContentChange(newContent) { diff --git a/apps/server/src/routes/api/image.ts b/apps/server/src/routes/api/image.ts index 60854e6f24..026e069c0d 100644 --- a/apps/server/src/routes/api/image.ts +++ b/apps/server/src/routes/api/image.ts @@ -23,7 +23,7 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) { if (!image) { res.set("Content-Type", "image/png"); return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`)); - } else if (!["image", "canvas", "mermaid", "mindMap"].includes(image.type)) { + } else if (!["image", "canvas", "mermaid", "mindMap", "spreadsheet"].includes(image.type)) { return res.sendStatus(400); } @@ -33,6 +33,8 @@ function returnImageInt(image: BNote | BRevision | null, res: Response) { renderSvgAttachment(image, res, "mermaid-export.svg"); } else if (image.type === "mindMap") { renderSvgAttachment(image, res, "mindmap-export.svg"); + } else if (image.type === "spreadsheet") { + renderPngAttachment(image, res, "spreadsheet-export.png"); } else { res.set("Content-Type", image.mime); res.set("Cache-Control", "no-cache, no-store, must-revalidate"); @@ -60,6 +62,18 @@ export function renderSvgAttachment(image: BNote | BRevision, res: Response, att res.send(svg); } +export function renderPngAttachment(image: BNote | BRevision, res: Response, attachmentName: string) { + const attachment = image.getAttachmentByTitle(attachmentName); + + if (attachment) { + res.set("Content-Type", "image/png"); + res.set("Cache-Control", "no-cache, no-store, must-revalidate"); + res.send(attachment.getContent()); + } else { + res.sendStatus(404); + } +} + function returnAttachedImage(req: Request<{ attachmentId: string }>, res: Response) { const attachment = becca.getAttachment(req.params.attachmentId); diff --git a/apps/server/src/services/notes.ts b/apps/server/src/services/notes.ts index f4eb40579a..708cab285d 100644 --- a/apps/server/src/services/notes.ts +++ b/apps/server/src/services/notes.ts @@ -772,16 +772,20 @@ function updateNoteData(noteId: string, content: string, attachments: Attachment if (attachments?.length > 0) { const existingAttachmentsByTitle = toMap(note.getAttachments(), "title"); - for (const { attachmentId, role, mime, title, position, content } of attachments) { + for (const { attachmentId, role, mime, title, position, content, encoding } of attachments) { + const decodedContent = encoding === "base64" && typeof content === "string" + ? Buffer.from(content, "base64") + : content; + const existingAttachment = existingAttachmentsByTitle.get(title); if (attachmentId || !existingAttachment) { - note.saveAttachment({ attachmentId, role, mime, title, content, position }); + note.saveAttachment({ attachmentId, role, mime, title, content: decodedContent, position }); } else { existingAttachment.role = role; existingAttachment.mime = mime; existingAttachment.position = position; - if (content) { - existingAttachment.setContent(content, { forceSave: true }); + if (decodedContent) { + existingAttachment.setContent(decodedContent, { forceSave: true }); } } } diff --git a/packages/commons/src/lib/rows.ts b/packages/commons/src/lib/rows.ts index e35d10e05f..2b06e5a6e1 100644 --- a/packages/commons/src/lib/rows.ts +++ b/packages/commons/src/lib/rows.ts @@ -17,6 +17,8 @@ export interface AttachmentRow { deleteId?: string; contentLength?: number; content?: Buffer | string; + /** If set to `"base64"`, the `content` string will be decoded from base64 to binary before storage. */ + encoding?: "base64"; } export interface RevisionRow {