mirror of
https://github.com/zadam/trilium.git
synced 2025-11-11 17:08:58 +01:00
chore(react/type_widget): set up canvas persistence
This commit is contained in:
parent
8c85aa343c
commit
68bf5b7e68
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user