chore(react/type_widget): set up canvas persistence

This commit is contained in:
Elian Doran 2025-09-22 09:22:09 +03:00
parent 8c85aa343c
commit 68bf5b7e68
No known key found for this signature in database
3 changed files with 245 additions and 253 deletions

View File

@ -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<ExcalidrawImperativeAPI>(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 (
<div className="canvas-widget note-detail-canvas note-detail-printable note-detail full-height">
@ -49,9 +45,240 @@ export default function Canvas({ note }: TypeWidgetProps) {
export: false
}
}}
{...persistence}
/>
</div>
</div>
</div>
)
}
function usePersistence(note: FNote, apiRef: RefObject<ExcalidrawImperativeAPI>, theme: AppState["theme"]): Partial<ExcalidrawProps> {
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<LibraryItem[]>([]);
const attachmentMetadata = useRef<AttachmentMetadata[]>([]);
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<string, BinaryFileData> = {};
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<AppState> = 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 };
});
}

View File

@ -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;

View File

@ -68,91 +68,6 @@ export default class Canvas {
return !!this.excalidrawApi;
}
loadData(content: CanvasContent, theme: Theme) {
const { elements, files } = content;
const appState: Partial<AppState> = 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<string, BinaryFileData> = {};
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) {