chore(react/type_widget): finalize porting canvas

This commit is contained in:
Elian Doran 2025-09-22 10:40:57 +03:00
parent 44b92a024c
commit 58a6d70cbb
No known key found for this signature in database
4 changed files with 36 additions and 212 deletions

View File

@ -289,7 +289,7 @@ function goToLink(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent) {
* @param $link the jQuery element of the link that was clicked, used to determine if the link is an anchor link (e.g., `#fn1` or `#fnref1`) and to handle it accordingly.
* @returns `true` if the link was handled (i.e., the element was found and scrolled to), or a falsy value otherwise.
*/
function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
export function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent<HTMLCanvasElement> | null, hrefLink: string | undefined, $link?: JQuery<HTMLElement> | null) {
if (hrefLink?.startsWith("data:")) {
return true;
}

View File

@ -2,7 +2,7 @@ import { Excalidraw, exportToSvg, getSceneVersion } from "@excalidraw/excalidraw
import { TypeWidgetProps } from "./type_widget";
import "@excalidraw/excalidraw/index.css";
import { useEditorSpacedUpdate } from "../react/hooks";
import { useMemo, useRef } from "preact/hooks";
import { useCallback, useMemo, useRef } from "preact/hooks";
import { type ExcalidrawImperativeAPI, type AppState, type BinaryFileData, LibraryItem, ExcalidrawProps } from "@excalidraw/excalidraw/types";
import options from "../../services/options";
import "./Canvas.css";
@ -11,6 +11,7 @@ import { RefObject } from "preact";
import server from "../../services/server";
import { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
import { CanvasContent } from "../type_widgets_old/canvas_el";
import { goToLinkExt } from "../../services/link";
// currently required by excalidraw, in order to allows self-hosting fonts locally.
// this avoids making excalidraw load the fonts from an external CDN.
@ -30,8 +31,31 @@ export default function Canvas({ note }: TypeWidgetProps) {
}, []);
const persistence = usePersistence(note, apiRef, themeStyle, isReadOnly);
/** Use excalidraw's native zoom instead of the global zoom. */
const onWheel = useCallback((e: MouseEvent) => {
if (e.ctrlKey) {
e.preventDefault();
e.stopPropagation();
}
}, []);
const onLinkOpen = useCallback((element: NonDeletedExcalidrawElement, event: CustomEvent) => {
let link = element.link;
if (!link) {
return false;
}
if (link.startsWith("root/")) {
link = "#" + link;
}
const { nativeEvent } = event.detail;
event.preventDefault();
return goToLinkExt(nativeEvent, link, null);
}, []);
return (
<div className="canvas-widget note-detail-canvas note-detail-printable note-detail full-height">
<div className="canvas-widget note-detail-canvas note-detail-printable note-detail full-height" onWheel={onWheel}>
<div className="canvas-render">
<div className="excalidraw-wrapper">
<Excalidraw
@ -49,6 +73,7 @@ export default function Canvas({ note }: TypeWidgetProps) {
export: false
}
}}
onLinkOpen={onLinkOpen}
{...persistence}
/>
</div>
@ -59,6 +84,14 @@ export default function Canvas({ note }: TypeWidgetProps) {
function usePersistence(note: FNote, apiRef: RefObject<ExcalidrawImperativeAPI>, theme: AppState["theme"], isReadOnly: boolean): Partial<ExcalidrawProps> {
const libraryChanged = useRef(false);
/**
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
* we compare the scene version as suggested in:
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
*
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
*/
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.

View File

@ -1,116 +0,0 @@
import TypeWidget from "./type_widget.js";
import server from "../../services/server.js";
import type FNote from "../../entities/fnote.js";
import options from "../../services/options.js";
import type { LibraryItem } from "@excalidraw/excalidraw/types";
import type Canvas from "./canvas_el.js";
import { CanvasContent } from "./canvas_el.js";
import { renderReactWidget } from "../react/react_utils.jsx";
import SpacedUpdate from "../../services/spaced_update.js";
import protected_session_holder from "../../services/protected_session_holder.js";
/**
* # Canvas note with excalidraw
* @author thfrei 2022-05-11
*
* Background:
* excalidraw gives great support for hand-drawn notes. It also allows including images and support
* for sketching. Excalidraw has a vibrant and active community.
*
* Functionality:
* We store the excalidraw assets (elements and files) in the note. In addition to that, we
* export the SVG from the canvas on every update and store it in the note's attachment. It is used when
* calling api/images and makes referencing very easy.
*
* Paths not taken.
* - excalidraw-to-svg (node.js) could be used to avoid storing the svg in the backend.
* We could render the SVG on the fly. However, as of now, it does not render any hand drawn
* (freedraw) paths. There is an issue with Path2D object not present in the node-canvas library
* used by jsdom. (See Trilium PR for samples and other issues in the respective library.
* Link will be added later). Related links:
* - https://github.com/Automattic/node-canvas/pull/2013
* - https://github.com/google/canvas-5-polyfill
* - https://github.com/Automattic/node-canvas/issues/1116
* - https://www.npmjs.com/package/path2d-polyfill
* - excalidraw-to-svg (node.js) takes quite some time to load an image (1-2s)
* - excalidraw-utils (browser) does render freedraw, however NOT freedraw with a background. It is not
* used, since it is a big dependency, and has the same functionality as react + excalidraw.
* - infinite-drawing-canvas with fabric.js. This library lacked a lot of features, excalidraw already
* has.
*
* Known issues:
* - the 3 excalidraw fonts should be included in the share and everywhere, so that it is shown
* when requiring svg.
*
* Discussion of storing svg in the note attachment:
* - Pro: we will combat bit-rot. Showing the SVG will be very fast and easy, since it is already there.
* - Con: The note will get bigger (~40-50%?), we will generate more bandwidth. However, using trilium
* desktop instance mitigates that issue.
*
* Roadmap:
* - Support image-notes as reference in excalidraw
* - Support canvas note as reference (svg) in other canvas notes.
* - Make it easy to include a canvas note inside a text note
*/
export default class ExcalidrawTypeWidget extends TypeWidget {
private currentNoteId: string;
private libraryChanged: boolean;
private librarycache: LibraryItem[];
private attachmentMetadata: AttachmentMetadata[];
private themeStyle!: Theme;
private $render!: JQuery<HTMLElement>;
private reactHandlers!: JQuery<HTMLElement>;
private canvasInstance!: Canvas;
constructor() {
super();
// temporary vars
this.currentNoteId = "";
// will be overwritten
this.$render;
this.$widget;
this.reactHandlers; // used to control react state
// 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 () => {
if (!this.noteContext) return;
const { note } = this.noteContext;
if (!note) return;
const { noteId } = note;
const data = await this.getData();
// for read only notes
if (data === undefined) return;
protected_session_holder.touchProtectedSessionIfNecessary(note);
await server.put(`notes/${noteId}/data`, data, this.componentId);
this.dataSaved();
});
}
doRender() {
this.$widget = $(TPL);
this.$widget.bind("mousewheel DOMMouseScroll", (event) => {
if (event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
return false;
}
});
this.$render = this.$widget.find(".canvas-render");
this.#init();
return this.$widget;
}
}

View File

@ -1,93 +0,0 @@
import { Excalidraw, getSceneVersion, exportToSvg } from "@excalidraw/excalidraw";
import { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem } from "@excalidraw/excalidraw/types";
import { ExcalidrawElement, NonDeletedExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
import { useCallback } from "preact/hooks";
import linkService from "../../services/link.js";
export interface CanvasContent {
elements: ExcalidrawElement[];
files: BinaryFileData[];
appState: Partial<AppState>;
}
/** Indicates that it is fresh. excalidraw scene version is always >0 */
const SCENE_VERSION_INITIAL = -1;
export default class Canvas {
private currentSceneVersion: number;
private opts: ExcalidrawProps;
private excalidrawApi!: ExcalidrawImperativeAPI;
constructor(opts: ExcalidrawProps) {
this.opts = opts;
this.currentSceneVersion = SCENE_VERSION_INITIAL;
this.initializedPromise = $.Deferred();
}
createCanvasElement() {
return <CanvasElement
{...this.opts}
/>
}
/**
* needed to ensure, that multipleOnChangeHandler calls do not trigger a save.
* we compare the scene version as suggested in:
* https://github.com/excalidraw/excalidraw/issues/3014#issuecomment-778115329
*
* info: sceneVersions are not incrementing. it seems to be a pseudo-random number
*/
isNewSceneVersion() {
const sceneVersion = this.getSceneVersion();
return (
this.currentSceneVersion === SCENE_VERSION_INITIAL || // initial scene version update
this.currentSceneVersion !== sceneVersion
); // ensure scene changed
}
getSceneVersion() {
const elements = this.excalidrawApi.getSceneElements();
return getSceneVersion(elements);
}
updateSceneVersion() {
this.currentSceneVersion = this.getSceneVersion();
}
resetSceneVersion() {
this.currentSceneVersion = SCENE_VERSION_INITIAL;
}
isInitialScene() {
return this.currentSceneVersion === SCENE_VERSION_INITIAL;
}
isInitialized() {
return !!this.excalidrawApi;
}
}
function CanvasElement(opts: ExcalidrawProps) {
return (
<Excalidraw
{...opts}
onLinkOpen={useCallback((element: NonDeletedExcalidrawElement, event: CustomEvent) => {
let link = element.link;
if (!link) {
return false;
}
if (link.startsWith("root/")) {
link = "#" + link;
}
const { nativeEvent } = event.detail;
event.preventDefault();
return linkService.goToLinkExt(nativeEvent, link, null);
}, [])}
/>
);
}