diff --git a/apps/client/src/widgets/type_widgets/Canvas.tsx b/apps/client/src/widgets/type_widgets/Canvas.tsx index 85d67b114..975b78a19 100644 --- a/apps/client/src/widgets/type_widgets/Canvas.tsx +++ b/apps/client/src/widgets/type_widgets/Canvas.tsx @@ -1,34 +1,30 @@ -import { Excalidraw } from "@excalidraw/excalidraw"; +import { Excalidraw, exportToSvg, getSceneVersion } from "@excalidraw/excalidraw"; import { TypeWidgetProps } from "./type_widget"; import "@excalidraw/excalidraw/index.css"; -import { useNoteBlob } from "../react/hooks"; +import { useEditorSpacedUpdate, useNoteBlob } from "../react/hooks"; import { useEffect, useMemo, useRef } from "preact/hooks"; -import type { ExcalidrawImperativeAPI, AppState } from "@excalidraw/excalidraw/types"; +import { type ExcalidrawImperativeAPI, type AppState, type BinaryFileData, LibraryItem, ExcalidrawProps } from "@excalidraw/excalidraw/types"; import options from "../../services/options"; import "./Canvas.css"; +import FNote from "../../entities/fnote"; +import { RefObject } from "preact"; +import server from "../../services/server"; +import { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; +import { CanvasContent } from "../type_widgets_old/canvas_el"; + +interface AttachmentMetadata { + title: string; + attachmentId: string; +} export default function Canvas({ note }: TypeWidgetProps) { const apiRef = useRef(null); - const blob = useNoteBlob(note); const viewModeEnabled = options.is("databaseReadonly"); const themeStyle = useMemo(() => { const documentStyle = window.getComputedStyle(document.documentElement); return documentStyle.getPropertyValue("--theme-style")?.trim() as AppState["theme"]; }, []); - - useEffect(() => { - const api = apiRef.current; - const content = blob?.content; - if (!api) return; - if (!content?.trim()) { - api.updateScene({ - elements: [], - appState: { - theme: themeStyle - } - }); - } - }, [ blob ]); + const persistence = usePersistence(note, apiRef, themeStyle); return (
@@ -49,9 +45,240 @@ export default function Canvas({ note }: TypeWidgetProps) { export: false } }} + {...persistence} />
) } + +function usePersistence(note: FNote, apiRef: RefObject, theme: AppState["theme"]): Partial { + const libraryChanged = useRef(false); + const currentSceneVersion = useRef(0); + + // these 2 variables are needed to compare the library state (all library items) after loading to the state when the library changed. So we can find attachments to be deleted. + //every libraryitem is saved on its own json file in the attachments of the note. + const libraryCache = useRef([]); + const attachmentMetadata = useRef([]); + + const spacedUpdate = useEditorSpacedUpdate({ + note, + onContentChange(newContent) { + const api = apiRef.current; + if (!api) return; + + libraryCache.current = []; + attachmentMetadata.current = []; + currentSceneVersion.current = -1; + + // load saved content into excalidraw canvas + let content: CanvasContent = { + elements: [], + files: [], + appState: {} + }; + if (newContent) { + try { + content = JSON.parse(newContent) as CanvasContent; + } catch (err) { + console.error("Error parsing content. Probably note.type changed. Starting with empty canvas", note, err); + } + } + + loadData(api, content, theme); + + // load the library state + loadLibrary(note).then(({ libraryItems, metadata }) => { + // Update the library and save to independent variables + api.updateLibrary({ libraryItems: libraryItems, merge: false }); + + // save state of library to compare it to the new state later. + libraryCache.current = libraryItems; + attachmentMetadata.current = metadata; + }); + }, + async getData() { + const api = apiRef.current; + if (!api) return; + const { content, svg } = await getData(api); + const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }]; + + if (libraryChanged.current) { + // this.libraryChanged is unset in dataSaved() + + // there's no separate method to get library items, so have to abuse this one + const libraryItems = await api.updateLibrary({ + libraryItems() { + return []; + }, + merge: true + }); + + // excalidraw saves the library as a own state. the items are saved to libraryItems. then we compare the library right now with a libraryitemcache. The cache is filled when we first load the Library into the note. + //We need the cache to delete old attachments later in the server. + + const libraryItemsMissmatch = libraryCache.current.filter((obj1) => !libraryItems.some((obj2: LibraryItem) => obj1.id === obj2.id)); + + // before we saved the metadata of the attachments in a cache. the title of the attachment is a combination of libraryitem ´s ID und it´s name. + // we compare the library items in the libraryitemmissmatch variable (this one saves all libraryitems that are different to the state right now. E.g. you delete 1 item, this item is saved as mismatch) + // then we combine its id and title and search the according attachmentID. + + const matchingItems = attachmentMetadata.current.filter((meta) => { + // Loop through the second array and check for a match + return libraryItemsMissmatch.some((item) => { + // Combine the `name` and `id` from the second array + const combinedTitle = `${item.id}${item.name}`; + return meta.title === combinedTitle; + }); + }); + + // we save the attachment ID`s in a variable and delete every attachmentID. Now the items that the user deleted will be deleted. + const attachmentIds = matchingItems.map((item) => item.attachmentId); + + //delete old attachments that are no longer used + for (const item of attachmentIds) { + await server.remove(`attachments/${item}`); + } + + let position = 10; + + // prepare data to save to server e.g. new library items. + for (const libraryItem of libraryItems) { + attachments.push({ + role: "canvasLibraryItem", + title: libraryItem.id + libraryItem.name, + mime: "application/json", + content: JSON.stringify(libraryItem), + position: position + }); + + position += 10; + } + } + + return { + content: JSON.stringify(content), + attachments + }; + }, + dataSaved() { + libraryChanged.current = false; + } + }); + + return { + onChange: () => { + if (!apiRef.current) return; + const oldSceneVersion = currentSceneVersion.current; + const newSceneVersion = getSceneVersion(apiRef.current.getSceneElements()); + + if (newSceneVersion !== oldSceneVersion) { + spacedUpdate.resetUpdateTimer(); + spacedUpdate.scheduleUpdate(); + currentSceneVersion.current = newSceneVersion; + } + }, + onLibraryChange: () => { + libraryChanged.current = true; + spacedUpdate.resetUpdateTimer(); + spacedUpdate.scheduleUpdate(); + } + } +} + +async function getData(api: ExcalidrawImperativeAPI) { + const elements = api.getSceneElements(); + const appState = api.getAppState(); + + /** + * A file is not deleted, even though removed from canvas. Therefore, we only keep + * files that are referenced by an element. Maybe this will change with a new excalidraw version? + */ + const files = api.getFiles(); + // parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share + const svg = await exportToSvg({ + elements, + appState, + exportPadding: 5, // 5 px padding + files + }); + const svgString = svg.outerHTML; + + const activeFiles: Record = {}; + elements.forEach((element: NonDeletedExcalidrawElement) => { + if ("fileId" in element && element.fileId) { + activeFiles[element.fileId] = files[element.fileId]; + } + }); + + const content = { + type: "excalidraw", + version: 2, + elements, + files: activeFiles, + appState: { + scrollX: appState.scrollX, + scrollY: appState.scrollY, + zoom: appState.zoom, + gridModeEnabled: appState.gridModeEnabled + } + }; + + return { + content, + svg: svgString + } +} + +function loadData(api: ExcalidrawImperativeAPI, content: CanvasContent, theme: AppState["theme"]) { + const { elements, files } = content; + const appState: Partial = content.appState ?? {}; + appState.theme = theme; + + // files are expected in an array when loading. they are stored as a key-index object + // see example for loading here: + // https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68 + const fileArray: BinaryFileData[] = []; + for (const fileId in files) { + const file = files[fileId]; + // TODO: dataURL is replaceable with a trilium image url + // maybe we can save normal images (pasted) with base64 data url, and trilium images + // with their respective url! nice + // file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png"; + fileArray.push(file); + } + + // Update the scene + // TODO: Fix type of sceneData + api.updateScene({ + elements, + appState: appState as AppState + }); + api.addFiles(fileArray); + api.history.clear(); +} + +async function loadLibrary(note: FNote) { + return Promise.all( + (await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => { + const blob = await attachment.getBlob(); + return { + blob, // Save the blob for libraryItems + metadata: { + // metadata to use in the cache variables for comparing old library state and new one. We delete unnecessary items later, calling the server directly + attachmentId: attachment.attachmentId, + title: attachment.title + } + }; + }) + ).then((results) => { + // Extract libraryItems from the blobs + const libraryItems = results.map((result) => result?.blob?.getJsonContentSafely()).filter((item) => !!item) as LibraryItem[]; + + // Extract metadata for each attachment + const metadata = results.map((result) => result.metadata); + + return { libraryItems, metadata }; + }); +} diff --git a/apps/client/src/widgets/type_widgets_old/canvas.ts b/apps/client/src/widgets/type_widgets_old/canvas.ts index b1b625412..bd630fa6d 100644 --- a/apps/client/src/widgets/type_widgets_old/canvas.ts +++ b/apps/client/src/widgets/type_widgets_old/canvas.ts @@ -9,11 +9,6 @@ import { renderReactWidget } from "../react/react_utils.jsx"; import SpacedUpdate from "../../services/spaced_update.js"; import protected_session_holder from "../../services/protected_session_holder.js"; -interface AttachmentMetadata { - title: string; - attachmentId: string; -} - /** * # Canvas note with excalidraw * @author thfrei 2022-05-11 @@ -81,13 +76,6 @@ export default class ExcalidrawTypeWidget extends TypeWidget { this.$widget; this.reactHandlers; // used to control react state - this.libraryChanged = false; - - // these 2 variables are needed to compare the library state (all library items) after loading to the state when the library changed. So we can find attachments to be deleted. - //every libraryitem is saved on its own json file in the attachments of the note. - this.librarycache = []; - this.attachmentMetadata = []; - // TODO: We are duplicating the logic of note_detail.ts because it switches note ID mid-save, causing overwrites. // This problem will get solved by itself once type widgets will be rewritten in React without the use of dangerous singletons. this.spacedUpdate = new SpacedUpdate(async () => { @@ -155,144 +143,10 @@ export default class ExcalidrawTypeWidget extends TypeWidget { await this.#init(); } - // see if the note changed, since we do not get a new class for a new note - const noteChanged = this.currentNoteId !== note.noteId; - if (noteChanged) { - // reset the scene to omit unnecessary onchange handler - this.canvasInstance.resetSceneVersion(); - } this.currentNoteId = note.noteId; // get note from backend and put into canvas const blob = await note.getBlob(); - - /** - * new and empty note - make sure that canvas is empty. - * If we do not set it manually, we occasionally get some "bleeding" from another - * note into this fresh note. Probably due to that this note-instance does not get - * newly instantiated? - */ - if (!blob?.content?.trim()) { - - } else if (blob.content) { - let content: CanvasContent; - - // load saved content into excalidraw canvas - try { - content = blob.getJsonContent() as CanvasContent; - } catch (err) { - console.error("Error parsing content. Probably note.type changed. Starting with empty canvas", note, blob, err); - - content = { - elements: [], - files: [], - appState: {} - }; - } - - this.canvasInstance.loadData(content, this.themeStyle); - - Promise.all( - (await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => { - const blob = await attachment.getBlob(); - return { - blob, // Save the blob for libraryItems - metadata: { - // metadata to use in the cache variables for comparing old library state and new one. We delete unnecessary items later, calling the server directly - attachmentId: attachment.attachmentId, - title: attachment.title - } - }; - }) - ).then((results) => { - if (note.noteId !== this.currentNoteId) { - // current note changed in the course of the async operation - return; - } - - // Extract libraryItems from the blobs - const libraryItems = results.map((result) => result?.blob?.getJsonContentSafely()).filter((item) => !!item) as LibraryItem[]; - - // Extract metadata for each attachment - const metadata = results.map((result) => result.metadata); - - // Update the library and save to independent variables - this.canvasInstance.updateLibrary(libraryItems); - - // save state of library to compare it to the new state later. - this.librarycache = libraryItems; - this.attachmentMetadata = metadata; - }); - - - } - - // set initial scene version - if (this.canvasInstance.isInitialScene()) { - this.canvasInstance.updateSceneVersion(); - } - } - - /** - * gets data from widget container that will be sent via spacedUpdate.scheduleUpdate(); - * this is automatically called after this.saveData(); - */ - async getData() { - const { content, svg } = await this.canvasInstance.getData(); - const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }]; - - if (this.libraryChanged) { - // this.libraryChanged is unset in dataSaved() - - // there's no separate method to get library items, so have to abuse this one - const libraryItems = await this.canvasInstance.getLibraryItems(); - - // excalidraw saves the library as a own state. the items are saved to libraryItems. then we compare the library right now with a libraryitemcache. The cache is filled when we first load the Library into the note. - //We need the cache to delete old attachments later in the server. - - const libraryItemsMissmatch = this.librarycache.filter((obj1) => !libraryItems.some((obj2: LibraryItem) => obj1.id === obj2.id)); - - // before we saved the metadata of the attachments in a cache. the title of the attachment is a combination of libraryitem ´s ID und it´s name. - // we compare the library items in the libraryitemmissmatch variable (this one saves all libraryitems that are different to the state right now. E.g. you delete 1 item, this item is saved as mismatch) - // then we combine its id and title and search the according attachmentID. - - const matchingItems = this.attachmentMetadata.filter((meta) => { - // Loop through the second array and check for a match - return libraryItemsMissmatch.some((item) => { - // Combine the `name` and `id` from the second array - const combinedTitle = `${item.id}${item.name}`; - return meta.title === combinedTitle; - }); - }); - - // we save the attachment ID`s in a variable and delete every attachmentID. Now the items that the user deleted will be deleted. - const attachmentIds = matchingItems.map((item) => item.attachmentId); - - //delete old attachments that are no longer used - for (const item of attachmentIds) { - await server.remove(`attachments/${item}`); - } - - let position = 10; - - // prepare data to save to server e.g. new library items. - for (const libraryItem of libraryItems) { - attachments.push({ - role: "canvasLibraryItem", - title: libraryItem.id + libraryItem.name, - mime: "application/json", - content: JSON.stringify(libraryItem), - position: position - }); - - position += 10; - } - } - - return { - content: JSON.stringify(content), - attachments - }; } /** @@ -304,10 +158,6 @@ export default class ExcalidrawTypeWidget extends TypeWidget { this.spacedUpdate.scheduleUpdate(); } - dataSaved() { - this.libraryChanged = false; - } - onChangeHandler() { if (options.is("databaseReadonly")) { return; diff --git a/apps/client/src/widgets/type_widgets_old/canvas_el.tsx b/apps/client/src/widgets/type_widgets_old/canvas_el.tsx index b345e7e08..410269261 100644 --- a/apps/client/src/widgets/type_widgets_old/canvas_el.tsx +++ b/apps/client/src/widgets/type_widgets_old/canvas_el.tsx @@ -68,91 +68,6 @@ export default class Canvas { return !!this.excalidrawApi; } - loadData(content: CanvasContent, theme: Theme) { - const { elements, files } = content; - const appState: Partial = content.appState ?? {}; - appState.theme = theme; - - // files are expected in an array when loading. they are stored as a key-index object - // see example for loading here: - // https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68 - const fileArray: BinaryFileData[] = []; - for (const fileId in files) { - const file = files[fileId]; - // TODO: dataURL is replaceable with a trilium image url - // maybe we can save normal images (pasted) with base64 data url, and trilium images - // with their respective url! nice - // file.dataURL = "http://localhost:8080/api/images/ltjOiU8nwoZx/start.png"; - fileArray.push(file); - } - - // Update the scene - // TODO: Fix type of sceneData - this.excalidrawApi.updateScene({ - elements, - appState: appState as AppState - }); - this.excalidrawApi.addFiles(fileArray); - this.excalidrawApi.history.clear(); - } - - async getData() { - const elements = this.excalidrawApi.getSceneElements(); - const appState = this.excalidrawApi.getAppState(); - - /** - * A file is not deleted, even though removed from canvas. Therefore, we only keep - * files that are referenced by an element. Maybe this will change with a new excalidraw version? - */ - const files = this.excalidrawApi.getFiles(); - // parallel svg export to combat bitrot and enable rendering image for note inclusion, preview, and share - const svg = await exportToSvg({ - elements, - appState, - exportPadding: 5, // 5 px padding - files - }); - const svgString = svg.outerHTML; - - const activeFiles: Record = {}; - elements.forEach((element: NonDeletedExcalidrawElement) => { - if ("fileId" in element && element.fileId) { - activeFiles[element.fileId] = files[element.fileId]; - } - }); - - const content = { - type: "excalidraw", - version: 2, - elements, - files: activeFiles, - appState: { - scrollX: appState.scrollX, - scrollY: appState.scrollY, - zoom: appState.zoom, - gridModeEnabled: appState.gridModeEnabled - } - }; - - return { - content, - svg: svgString - } - } - - async getLibraryItems() { - return this.excalidrawApi.updateLibrary({ - libraryItems() { - return []; - }, - merge: true - }); - } - - async updateLibrary(libraryItems: LibraryItem[]) { - this.excalidrawApi.updateLibrary({ libraryItems, merge: false }); - } - } function CanvasElement(opts: ExcalidrawProps) {