mirror of
https://github.com/TriliumNext/Notes.git
synced 2025-07-27 10:02:59 +08:00
Merge branch 'develop' into renovate/softprops-action-gh-release-2.x
This commit is contained in:
commit
fb9f5a7584
49
.github/workflows/ci.yml
vendored
49
.github/workflows/ci.yml
vendored
@ -1,49 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
actions: read
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
main:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
filter: tree:0
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
|
||||||
name: Install pnpm
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
# This enables task distribution via Nx Cloud
|
|
||||||
# Run this command as early as possible, before dependencies are installed
|
|
||||||
# Learn more at https://nx.dev/ci/reference/nx-cloud-cli#npx-nxcloud-startcirun
|
|
||||||
# Connect your workspace by running "nx connect" and uncomment this line to enable task distribution
|
|
||||||
# - run: pnpm dlx nx-cloud start-ci-run --distribute-on="3 linux-medium-js" --stop-agents-after="e2e-ci"
|
|
||||||
|
|
||||||
|
|
||||||
# Cache node_modules
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: 'pnpm'
|
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
|
||||||
- run: pnpm exec playwright install --with-deps
|
|
||||||
- uses: nrwl/nx-set-shas@v4
|
|
||||||
|
|
||||||
# Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud
|
|
||||||
# - run: pnpm exec nx-cloud record -- echo Hello World
|
|
||||||
# Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected
|
|
||||||
# When you enable task distribution, run the e2e-ci task instead of e2e
|
|
||||||
- run: pnpm exec nx affected -t lint test build e2e
|
|
@ -35,10 +35,10 @@
|
|||||||
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
"chore:generate-openapi": "tsx bin/generate-openapi.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.52.0",
|
"@playwright/test": "1.53.0",
|
||||||
"@stylistic/eslint-plugin": "4.4.1",
|
"@stylistic/eslint-plugin": "4.4.1",
|
||||||
"@types/express": "5.0.3",
|
"@types/express": "5.0.3",
|
||||||
"@types/node": "22.15.30",
|
"@types/node": "22.15.31",
|
||||||
"@types/yargs": "17.0.33",
|
"@types/yargs": "17.0.33",
|
||||||
"@vitest/coverage-v8": "3.2.3",
|
"@vitest/coverage-v8": "3.2.3",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.28.0",
|
||||||
|
@ -51,8 +51,7 @@
|
|||||||
"mind-elixir": "4.6.0",
|
"mind-elixir": "4.6.0",
|
||||||
"normalize.css": "8.0.1",
|
"normalize.css": "8.0.1",
|
||||||
"panzoom": "9.4.3",
|
"panzoom": "9.4.3",
|
||||||
"react": "19.1.0",
|
"preact": "10.26.8",
|
||||||
"react-dom": "19.1.0",
|
|
||||||
"split.js": "1.6.5",
|
"split.js": "1.6.5",
|
||||||
"svg-pan-zoom": "3.6.2",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"vanilla-js-wheel-zoom": "9.0.4"
|
"vanilla-js-wheel-zoom": "9.0.4"
|
||||||
@ -64,10 +63,8 @@
|
|||||||
"@types/leaflet": "1.9.18",
|
"@types/leaflet": "1.9.18",
|
||||||
"@types/leaflet-gpx": "1.3.7",
|
"@types/leaflet-gpx": "1.3.7",
|
||||||
"@types/mark.js": "8.11.12",
|
"@types/mark.js": "8.11.12",
|
||||||
"@types/react": "19.1.7",
|
|
||||||
"@types/react-dom": "19.1.6",
|
|
||||||
"copy-webpack-plugin": "13.0.0",
|
"copy-webpack-plugin": "13.0.0",
|
||||||
"happy-dom": "17.6.3",
|
"happy-dom": "18.0.1",
|
||||||
"script-loader": "0.7.2",
|
"script-loader": "0.7.2",
|
||||||
"vite-plugin-static-copy": "3.0.0"
|
"vite-plugin-static-copy": "3.0.0"
|
||||||
},
|
},
|
||||||
@ -75,7 +72,9 @@
|
|||||||
"name": "client",
|
"name": "client",
|
||||||
"targets": {
|
"targets": {
|
||||||
"serve": {
|
"serve": {
|
||||||
"dependsOn": ["^build"]
|
"dependsOn": [
|
||||||
|
"^build"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2
apps/client/src/types-assets.d.ts
vendored
2
apps/client/src/types-assets.d.ts
vendored
@ -3,7 +3,7 @@ declare module "*.png" {
|
|||||||
export default path;
|
export default path;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@triliumnext/ckeditor5/emoji_definitions/en.json?url" {
|
declare module "*?url" {
|
||||||
var path: string;
|
var path: string;
|
||||||
export default path;
|
export default path;
|
||||||
}
|
}
|
||||||
|
2
apps/client/src/types.d.ts
vendored
2
apps/client/src/types.d.ts
vendored
@ -57,6 +57,8 @@ declare global {
|
|||||||
|
|
||||||
process?: ElectronProcess;
|
process?: ElectronProcess;
|
||||||
glob?: CustomGlobals;
|
glob?: CustomGlobals;
|
||||||
|
|
||||||
|
EXCALIDRAW_ASSET_PATH?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AutoCompleteConfig {
|
interface AutoCompleteConfig {
|
||||||
|
@ -1,16 +1,11 @@
|
|||||||
import TypeWidget from "./type_widget.js";
|
import TypeWidget from "./type_widget.js";
|
||||||
import utils from "../../services/utils.js";
|
|
||||||
import linkService from "../../services/link.js";
|
|
||||||
import server from "../../services/server.js";
|
import server from "../../services/server.js";
|
||||||
import type FNote from "../../entities/fnote.js";
|
import type FNote from "../../entities/fnote.js";
|
||||||
import options from "../../services/options.js";
|
import options from "../../services/options.js";
|
||||||
import type { ExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
|
import type { LibraryItem } from "@excalidraw/excalidraw/types";
|
||||||
import type { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem, SceneData } from "@excalidraw/excalidraw/types";
|
import type { Theme } from "@excalidraw/excalidraw/element/types";
|
||||||
import type { JSX } from "react";
|
import type Canvas from "./canvas_el.js";
|
||||||
import type React from "react";
|
import { CanvasContent } from "./canvas_el.js";
|
||||||
import type { Root } from "react-dom/client";
|
|
||||||
import "@excalidraw/excalidraw/index.css";
|
|
||||||
import asset_path from "../../asset_path.js";
|
|
||||||
|
|
||||||
const TPL = /*html*/`
|
const TPL = /*html*/`
|
||||||
<div class="canvas-widget note-detail-canvas note-detail-printable note-detail">
|
<div class="canvas-widget note-detail-canvas note-detail-printable note-detail">
|
||||||
@ -28,6 +23,7 @@ const TPL = /*html*/`
|
|||||||
|
|
||||||
.excalidraw-wrapper {
|
.excalidraw-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
:root[dir="ltr"]
|
:root[dir="ltr"]
|
||||||
.excalidraw
|
.excalidraw
|
||||||
@ -51,11 +47,7 @@ const TPL = /*html*/`
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface CanvasContent {
|
|
||||||
elements: ExcalidrawElement[];
|
|
||||||
files: BinaryFileData[];
|
|
||||||
appState: Partial<AppState>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AttachmentMetadata {
|
interface AttachmentMetadata {
|
||||||
title: string;
|
title: string;
|
||||||
@ -107,37 +99,22 @@ interface AttachmentMetadata {
|
|||||||
*/
|
*/
|
||||||
export default class ExcalidrawTypeWidget extends TypeWidget {
|
export default class ExcalidrawTypeWidget extends TypeWidget {
|
||||||
|
|
||||||
private readonly SCENE_VERSION_INITIAL: number;
|
|
||||||
private readonly SCENE_VERSION_ERROR: number;
|
|
||||||
|
|
||||||
private currentNoteId: string;
|
private currentNoteId: string;
|
||||||
private currentSceneVersion: number;
|
|
||||||
private libraryChanged: boolean;
|
private libraryChanged: boolean;
|
||||||
private librarycache: LibraryItem[];
|
private librarycache: LibraryItem[];
|
||||||
private attachmentMetadata: AttachmentMetadata[];
|
private attachmentMetadata: AttachmentMetadata[];
|
||||||
private themeStyle!: Theme;
|
private themeStyle!: Theme;
|
||||||
private excalidrawLib!: typeof import("@excalidraw/excalidraw");
|
|
||||||
private excalidrawApi!: ExcalidrawImperativeAPI;
|
|
||||||
private excalidrawWrapperRef!: React.RefObject<HTMLElement | null>;
|
|
||||||
|
|
||||||
private $render!: JQuery<HTMLElement>;
|
private $render!: JQuery<HTMLElement>;
|
||||||
private root?: Root;
|
|
||||||
private reactHandlers!: JQuery<HTMLElement>;
|
private reactHandlers!: JQuery<HTMLElement>;
|
||||||
|
private canvasInstance!: Canvas;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
// constants
|
|
||||||
this.SCENE_VERSION_INITIAL = -1; // -1 indicates that it is fresh. excalidraw scene version is always >0
|
|
||||||
this.SCENE_VERSION_ERROR = -2; // -2 indicates error
|
|
||||||
|
|
||||||
// currently required by excalidraw, in order to allows self-hosting fonts locally.
|
|
||||||
// this avoids making excalidraw load the fonts from an external CDN.
|
|
||||||
(window as any).EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
|
|
||||||
|
|
||||||
// temporary vars
|
// temporary vars
|
||||||
this.currentNoteId = "";
|
this.currentNoteId = "";
|
||||||
this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
|
|
||||||
|
|
||||||
// will be overwritten
|
// will be overwritten
|
||||||
this.$render;
|
this.$render;
|
||||||
@ -182,34 +159,48 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
throw new Error("Unable to find element to render.");
|
throw new Error("Unable to find element to render.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// See https://github.com/excalidraw/excalidraw/issues/7899.
|
const Canvas = (await import("./canvas_el.js")).default;
|
||||||
if (!window.process) {
|
this.canvasInstance = new Canvas({
|
||||||
(window.process as any) = {};
|
// this makes sure that 1) manual theme switch button is hidden 2) theme stays as it should after opening menu
|
||||||
}
|
theme: this.themeStyle,
|
||||||
if (!window.process.env) {
|
onChange: () => this.onChangeHandler(),
|
||||||
window.process.env = {};
|
viewModeEnabled: options.is("databaseReadonly"),
|
||||||
}
|
zenModeEnabled: false,
|
||||||
(window.process.env as any).PREACT = false;
|
gridModeEnabled: false,
|
||||||
|
isCollaborating: false,
|
||||||
|
detectScroll: false,
|
||||||
|
handleKeyboardGlobally: false,
|
||||||
|
autoFocus: false,
|
||||||
|
UIOptions: {
|
||||||
|
canvasActions: {
|
||||||
|
saveToActiveFile: false,
|
||||||
|
export: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLibraryChange: () => {
|
||||||
|
this.libraryChanged = true;
|
||||||
|
|
||||||
const excalidraw = await import("@excalidraw/excalidraw");
|
this.saveData();
|
||||||
this.excalidrawLib = excalidraw;
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const { createRoot } = await import("react-dom/client");
|
await setupFonts();
|
||||||
const React = (await import("react")).default;
|
this.canvasInstance.renderCanvas(renderElement);
|
||||||
this.root?.unmount();
|
|
||||||
this.root = createRoot(renderElement);
|
|
||||||
this.root.render(React.createElement(() => this.createExcalidrawReactApp(React, excalidraw.Excalidraw)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* called to populate the widget container with the note content
|
* called to populate the widget container with the note content
|
||||||
*/
|
*/
|
||||||
async doRefresh(note: FNote) {
|
async doRefresh(note: FNote) {
|
||||||
|
if (!this.canvasInstance) {
|
||||||
|
await this.#init();
|
||||||
|
}
|
||||||
|
|
||||||
// see if the note changed, since we do not get a new class for a new note
|
// see if the note changed, since we do not get a new class for a new note
|
||||||
const noteChanged = this.currentNoteId !== note.noteId;
|
const noteChanged = this.currentNoteId !== note.noteId;
|
||||||
if (noteChanged) {
|
if (noteChanged) {
|
||||||
// reset the scene to omit unnecessary onchange handler
|
// reset the scene to omit unnecessary onchange handler
|
||||||
this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
|
this.canvasInstance.resetSceneVersion();
|
||||||
}
|
}
|
||||||
this.currentNoteId = note.noteId;
|
this.currentNoteId = note.noteId;
|
||||||
|
|
||||||
@ -217,10 +208,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
const blob = await note.getBlob();
|
const blob = await note.getBlob();
|
||||||
|
|
||||||
// before we load content into excalidraw, make sure excalidraw has loaded
|
// before we load content into excalidraw, make sure excalidraw has loaded
|
||||||
while (!this.excalidrawApi) {
|
await this.canvasInstance.waitForApiToBecomeAvailable();
|
||||||
console.log("excalidrawApi not yet loaded, sleep 200ms...");
|
|
||||||
await utils.sleep(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* new and empty note - make sure that canvas is empty.
|
* new and empty note - make sure that canvas is empty.
|
||||||
@ -229,15 +217,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
* newly instantiated?
|
* newly instantiated?
|
||||||
*/
|
*/
|
||||||
if (!blob?.content?.trim()) {
|
if (!blob?.content?.trim()) {
|
||||||
const sceneData: SceneData = {
|
this.canvasInstance.resetScene(this.themeStyle);
|
||||||
elements: [],
|
|
||||||
appState: {
|
|
||||||
theme: this.themeStyle
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Props mismatch.
|
|
||||||
this.excalidrawApi.updateScene(sceneData as any);
|
|
||||||
} else if (blob.content) {
|
} else if (blob.content) {
|
||||||
let content: CanvasContent;
|
let content: CanvasContent;
|
||||||
|
|
||||||
@ -254,36 +234,7 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { elements, files } = content;
|
this.canvasInstance.loadData(content, this.themeStyle);
|
||||||
const appState: Partial<AppState> = content.appState ?? {};
|
|
||||||
|
|
||||||
appState.theme = this.themeStyle;
|
|
||||||
|
|
||||||
if (this.excalidrawWrapperRef.current) {
|
|
||||||
const boundingClientRect = this.excalidrawWrapperRef.current.getBoundingClientRect();
|
|
||||||
appState.width = boundingClientRect.width;
|
|
||||||
appState.height = boundingClientRect.height;
|
|
||||||
appState.offsetLeft = boundingClientRect.left;
|
|
||||||
appState.offsetTop = boundingClientRect.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sceneData: SceneData = {
|
|
||||||
elements,
|
|
||||||
appState
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
Promise.all(
|
Promise.all(
|
||||||
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
|
(await note.getAttachmentsByRole("canvasLibraryItem")).map(async (attachment) => {
|
||||||
@ -310,23 +261,19 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
const metadata = results.map((result) => result.metadata);
|
const metadata = results.map((result) => result.metadata);
|
||||||
|
|
||||||
// Update the library and save to independent variables
|
// Update the library and save to independent variables
|
||||||
this.excalidrawApi.updateLibrary({ libraryItems, merge: false });
|
this.canvasInstance.updateLibrary(libraryItems);
|
||||||
|
|
||||||
// save state of library to compare it to the new state later.
|
// save state of library to compare it to the new state later.
|
||||||
this.librarycache = libraryItems;
|
this.librarycache = libraryItems;
|
||||||
this.attachmentMetadata = metadata;
|
this.attachmentMetadata = metadata;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update the scene
|
|
||||||
// TODO: Fix type of sceneData
|
|
||||||
this.excalidrawApi.updateScene(sceneData as any);
|
|
||||||
this.excalidrawApi.addFiles(fileArray);
|
|
||||||
this.excalidrawApi.history.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// set initial scene version
|
// set initial scene version
|
||||||
if (this.currentSceneVersion === this.SCENE_VERSION_INITIAL) {
|
if (this.canvasInstance.isInitialScene()) {
|
||||||
this.currentSceneVersion = this.getSceneVersion();
|
this.canvasInstance.updateSceneVersion();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -335,56 +282,14 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
* this is automatically called after this.saveData();
|
* this is automatically called after this.saveData();
|
||||||
*/
|
*/
|
||||||
async getData() {
|
async getData() {
|
||||||
const elements = this.excalidrawApi.getSceneElements();
|
const { content, svg } = await this.canvasInstance.getData();
|
||||||
const appState = this.excalidrawApi.getAppState();
|
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svg, position: 0 }];
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 this.excalidrawLib.exportToSvg({
|
|
||||||
elements,
|
|
||||||
appState,
|
|
||||||
exportPadding: 5, // 5 px padding
|
|
||||||
files
|
|
||||||
});
|
|
||||||
const svgString = svg.outerHTML;
|
|
||||||
|
|
||||||
const activeFiles: Record<string, BinaryFileData> = {};
|
|
||||||
// TODO: Used any where upstream typings appear to be broken.
|
|
||||||
elements.forEach((element: any) => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const attachments = [{ role: "image", title: "canvas-export.svg", mime: "image/svg+xml", content: svgString, position: 0 }];
|
|
||||||
|
|
||||||
if (this.libraryChanged) {
|
if (this.libraryChanged) {
|
||||||
// this.libraryChanged is unset in dataSaved()
|
// this.libraryChanged is unset in dataSaved()
|
||||||
|
|
||||||
// there's no separate method to get library items, so have to abuse this one
|
// there's no separate method to get library items, so have to abuse this one
|
||||||
const libraryItems = await this.excalidrawApi.updateLibrary({
|
const libraryItems = await this.canvasInstance.getLibraryItems();
|
||||||
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.
|
// 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.
|
//We need the cache to delete old attachments later in the server.
|
||||||
@ -453,146 +358,39 @@ export default class ExcalidrawTypeWidget extends TypeWidget {
|
|||||||
}
|
}
|
||||||
// changeHandler is called upon any tiny change in excalidraw. button clicked, hover, etc.
|
// changeHandler is called upon any tiny change in excalidraw. button clicked, hover, etc.
|
||||||
// make sure only when a new element is added, we actually save something.
|
// make sure only when a new element is added, we actually save something.
|
||||||
const isNewSceneVersion = this.isNewSceneVersion();
|
const isNewSceneVersion = this.canvasInstance.isNewSceneVersion();
|
||||||
/**
|
/**
|
||||||
* FIXME: however, we might want to make an exception, if viewport changed, since viewport
|
* FIXME: however, we might want to make an exception, if viewport changed, since viewport
|
||||||
* is desired to save? (add) and appState background, and some things
|
* is desired to save? (add) and appState background, and some things
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// upon updateScene, onchange is called, even though "nothing really changed" that is worth saving
|
// upon updateScene, onchange is called, even though "nothing really changed" that is worth saving
|
||||||
const isNotInitialScene = this.currentSceneVersion !== this.SCENE_VERSION_INITIAL;
|
const isNotInitialScene = !this.canvasInstance.isInitialScene();
|
||||||
|
|
||||||
const shouldSave = isNewSceneVersion && isNotInitialScene;
|
const shouldSave = isNewSceneVersion && isNotInitialScene;
|
||||||
|
|
||||||
if (shouldSave) {
|
if (shouldSave) {
|
||||||
this.updateSceneVersion();
|
this.canvasInstance.updateSceneVersion();
|
||||||
this.saveData();
|
this.saveData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createExcalidrawReactApp(react: typeof React, excalidrawComponent: React.MemoExoticComponent<(props: ExcalidrawProps) => JSX.Element>) {
|
}
|
||||||
const excalidrawWrapperRef = react.useRef<HTMLElement>(null);
|
|
||||||
this.excalidrawWrapperRef = excalidrawWrapperRef;
|
async function setupFonts() {
|
||||||
const [dimensions, setDimensions] = react.useState<{ width?: number; height?: number }>({
|
if (window.EXCALIDRAW_ASSET_PATH) {
|
||||||
width: undefined,
|
return;
|
||||||
height: undefined
|
}
|
||||||
});
|
|
||||||
|
// currently required by excalidraw, in order to allows self-hosting fonts locally.
|
||||||
react.useEffect(() => {
|
// this avoids making excalidraw load the fonts from an external CDN.
|
||||||
if (excalidrawWrapperRef.current) {
|
let path: string;
|
||||||
const dimensions = {
|
if (!glob.isDev) {
|
||||||
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
|
path = `${window.location.pathname}/node_modules/@excalidraw/excalidraw/dist/prod`;
|
||||||
height: excalidrawWrapperRef.current.getBoundingClientRect().height
|
} else {
|
||||||
};
|
path = (await import("../../../node_modules/@excalidraw/excalidraw/dist/prod/fonts/Excalifont/Excalifont-Regular-a88b72a24fb54c9f94e3b5fdaa7481c9.woff2?url")).default;
|
||||||
setDimensions(dimensions);
|
let pathComponents = path.split("/");
|
||||||
}
|
path = pathComponents.slice(0, pathComponents.length - 2).join("/");
|
||||||
|
}
|
||||||
const onResize = () => {
|
|
||||||
if (this.note?.type !== "canvas") {
|
window.EXCALIDRAW_ASSET_PATH = path;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (excalidrawWrapperRef.current) {
|
|
||||||
const dimensions = {
|
|
||||||
width: excalidrawWrapperRef.current.getBoundingClientRect().width,
|
|
||||||
height: excalidrawWrapperRef.current.getBoundingClientRect().height
|
|
||||||
};
|
|
||||||
setDimensions(dimensions);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", onResize);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("resize", onResize);
|
|
||||||
}, [excalidrawWrapperRef]);
|
|
||||||
|
|
||||||
const onLinkOpen = react.useCallback<NonNullable<ExcalidrawProps["onLinkOpen"]>>((element, event) => {
|
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return react.createElement(
|
|
||||||
react.Fragment,
|
|
||||||
null,
|
|
||||||
react.createElement(
|
|
||||||
"div",
|
|
||||||
{
|
|
||||||
className: "excalidraw-wrapper",
|
|
||||||
ref: excalidrawWrapperRef
|
|
||||||
},
|
|
||||||
react.createElement(excalidrawComponent, {
|
|
||||||
// this makes sure that 1) manual theme switch button is hidden 2) theme stays as it should after opening menu
|
|
||||||
theme: this.themeStyle,
|
|
||||||
excalidrawAPI: (api: ExcalidrawImperativeAPI) => {
|
|
||||||
this.excalidrawApi = api;
|
|
||||||
},
|
|
||||||
onLibraryChange: () => {
|
|
||||||
this.libraryChanged = true;
|
|
||||||
|
|
||||||
this.saveData();
|
|
||||||
},
|
|
||||||
onChange: () => this.onChangeHandler(),
|
|
||||||
viewModeEnabled: options.is("databaseReadonly"),
|
|
||||||
zenModeEnabled: false,
|
|
||||||
gridModeEnabled: false,
|
|
||||||
isCollaborating: false,
|
|
||||||
detectScroll: false,
|
|
||||||
handleKeyboardGlobally: false,
|
|
||||||
autoFocus: false,
|
|
||||||
onLinkOpen,
|
|
||||||
UIOptions: {
|
|
||||||
canvasActions: {
|
|
||||||
saveToActiveFile: false,
|
|
||||||
export: 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
|
|
||||||
*/
|
|
||||||
isNewSceneVersion() {
|
|
||||||
if (options.is("databaseReadonly")) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sceneVersion = this.getSceneVersion();
|
|
||||||
|
|
||||||
return (
|
|
||||||
this.currentSceneVersion === this.SCENE_VERSION_INITIAL || // initial scene version update
|
|
||||||
this.currentSceneVersion !== sceneVersion
|
|
||||||
); // ensure scene changed
|
|
||||||
}
|
|
||||||
|
|
||||||
getSceneVersion() {
|
|
||||||
if (this.excalidrawApi) {
|
|
||||||
const elements = this.excalidrawApi.getSceneElements();
|
|
||||||
return this.excalidrawLib.getSceneVersion(elements);
|
|
||||||
} else {
|
|
||||||
return this.SCENE_VERSION_ERROR;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSceneVersion() {
|
|
||||||
this.currentSceneVersion = this.getSceneVersion();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
179
apps/client/src/widgets/type_widgets/canvas_el.ts
Normal file
179
apps/client/src/widgets/type_widgets/canvas_el.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import "@excalidraw/excalidraw/index.css";
|
||||||
|
import { Excalidraw, getSceneVersion, exportToSvg } from "@excalidraw/excalidraw";
|
||||||
|
import { createElement, render, unmountComponentAtNode } from "preact/compat";
|
||||||
|
import { AppState, BinaryFileData, ExcalidrawImperativeAPI, ExcalidrawProps, LibraryItem } from "@excalidraw/excalidraw/types";
|
||||||
|
import type { ComponentType } from "preact";
|
||||||
|
import { ExcalidrawElement, NonDeletedExcalidrawElement, Theme } from "@excalidraw/excalidraw/element/types";
|
||||||
|
|
||||||
|
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;
|
||||||
|
private initializedPromise: JQuery.Deferred<void>;
|
||||||
|
|
||||||
|
constructor(opts: ExcalidrawProps) {
|
||||||
|
this.opts = opts;
|
||||||
|
this.currentSceneVersion = SCENE_VERSION_INITIAL;
|
||||||
|
this.initializedPromise = $.Deferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCanvas(targetEl: HTMLElement) {
|
||||||
|
unmountComponentAtNode(targetEl);
|
||||||
|
render(this.createCanvasElement({
|
||||||
|
...this.opts,
|
||||||
|
excalidrawAPI: (api: ExcalidrawImperativeAPI) => {
|
||||||
|
this.excalidrawApi = api;
|
||||||
|
this.initializedPromise.resolve();
|
||||||
|
},
|
||||||
|
}), targetEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForApiToBecomeAvailable() {
|
||||||
|
while (!this.excalidrawApi) {
|
||||||
|
await this.initializedPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private createCanvasElement(opts: ExcalidrawProps) {
|
||||||
|
return createElement("div", { className: "excalidraw-wrapper", },
|
||||||
|
createElement(Excalidraw as ComponentType<ExcalidrawProps>, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetScene(theme: Theme) {
|
||||||
|
this.excalidrawApi.updateScene({
|
||||||
|
elements: [],
|
||||||
|
appState: {
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
content,
|
||||||
|
svg: svgString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLibraryItems() {
|
||||||
|
return this.excalidrawApi.updateLibrary({
|
||||||
|
libraryItems() {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
merge: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateLibrary(libraryItems: LibraryItem[]) {
|
||||||
|
this.excalidrawApi.updateLibrary({ libraryItems, merge: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -43,11 +43,22 @@ export default defineConfig(() => ({
|
|||||||
{
|
{
|
||||||
find: "@triliumnext/highlightjs",
|
find: "@triliumnext/highlightjs",
|
||||||
replacement: resolve(__dirname, "node_modules/@triliumnext/highlightjs/dist")
|
replacement: resolve(__dirname, "node_modules/@triliumnext/highlightjs/dist")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
find: "react",
|
||||||
|
replacement: "preact/compat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
find: "react-dom",
|
||||||
|
replacement: "preact/compat"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
dedupe: [
|
dedupe: [
|
||||||
"react",
|
"react",
|
||||||
"react-dom"
|
"react-dom",
|
||||||
|
"preact",
|
||||||
|
"preact/compat",
|
||||||
|
"preact/hooks"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
// Uncomment this if you are using workers.
|
// Uncomment this if you are using workers.
|
||||||
@ -97,5 +108,8 @@ export default defineConfig(() => ({
|
|||||||
},
|
},
|
||||||
commonjsOptions: {
|
commonjsOptions: {
|
||||||
transformMixedEsModules: true,
|
transformMixedEsModules: true,
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
"process.env.IS_PREACT": JSON.stringify("true"),
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
@ -88,13 +88,13 @@
|
|||||||
"multer": "2.0.1",
|
"multer": "2.0.1",
|
||||||
"normalize-strings": "1.1.1",
|
"normalize-strings": "1.1.1",
|
||||||
"ollama": "0.5.16",
|
"ollama": "0.5.16",
|
||||||
"openai": "5.2.0",
|
"openai": "5.3.0",
|
||||||
"rand-token": "1.0.1",
|
"rand-token": "1.0.1",
|
||||||
"safe-compare": "1.1.4",
|
"safe-compare": "1.1.4",
|
||||||
"sanitize-filename": "1.6.3",
|
"sanitize-filename": "1.6.3",
|
||||||
"sanitize-html": "2.17.0",
|
"sanitize-html": "2.17.0",
|
||||||
"sax": "1.4.1",
|
"sax": "1.4.1",
|
||||||
"serve-favicon": "2.5.0",
|
"serve-favicon": "2.5.1",
|
||||||
"stream-throttle": "0.1.3",
|
"stream-throttle": "0.1.3",
|
||||||
"strip-bom": "5.0.0",
|
"strip-bom": "5.0.0",
|
||||||
"striptags": "3.2.0",
|
"striptags": "3.2.0",
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
"@playwright/test": "^1.36.0",
|
"@playwright/test": "^1.36.0",
|
||||||
"@triliumnext/server": "workspace:*",
|
"@triliumnext/server": "workspace:*",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "22.15.30",
|
"@types/node": "22.15.31",
|
||||||
"@vitest/coverage-v8": "^3.0.5",
|
"@vitest/coverage-v8": "^3.0.5",
|
||||||
"@vitest/ui": "^3.0.0",
|
"@vitest/ui": "^3.0.0",
|
||||||
"chalk": "5.4.1",
|
"chalk": "5.4.1",
|
||||||
@ -49,7 +49,7 @@
|
|||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
"eslint-config-prettier": "^10.0.0",
|
"eslint-config-prettier": "^10.0.0",
|
||||||
"eslint-plugin-playwright": "^2.0.0",
|
"eslint-plugin-playwright": "^2.0.0",
|
||||||
"happy-dom": "~17.6.0",
|
"happy-dom": "~18.0.0",
|
||||||
"jiti": "2.4.2",
|
"jiti": "2.4.2",
|
||||||
"jsdom": "~26.1.0",
|
"jsdom": "~26.1.0",
|
||||||
"jsonc-eslint-parser": "^2.1.0",
|
"jsonc-eslint-parser": "^2.1.0",
|
||||||
@ -92,6 +92,9 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"node-abi": "4.9.0",
|
"node-abi": "4.9.0",
|
||||||
|
"mermaid": "11.6.0",
|
||||||
|
"preact": "10.26.8",
|
||||||
|
"roughjs": "4.6.6",
|
||||||
"@types/express-serve-static-core": "5.0.6",
|
"@types/express-serve-static-core": "5.0.6",
|
||||||
"flat@<5.0.1": ">=5.0.1",
|
"flat@<5.0.1": ">=5.0.1",
|
||||||
"debug@>=3.2.0 <3.2.7": ">=3.2.7",
|
"debug@>=3.2.0 <3.2.7": ">=3.2.7",
|
||||||
|
@ -23,12 +23,12 @@
|
|||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@digitak/esrun": "^3.2.24",
|
"@digitak/esrun": "^3.2.24",
|
||||||
"@types/swagger-ui": "^3.52.0",
|
"@types/swagger-ui": "^5.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
"@typescript-eslint/eslint-plugin": "^6.7.2",
|
||||||
"@typescript-eslint/parser": "^6.7.2",
|
"@typescript-eslint/parser": "^6.7.2",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"eslint": "^8.49.0",
|
"eslint": "^9.0.0",
|
||||||
"highlight.js": "^11.8.0",
|
"highlight.js": "^11.8.0",
|
||||||
"typescript": "^5.2.2"
|
"typescript": "^5.2.2"
|
||||||
},
|
},
|
||||||
|
1373
pnpm-lock.yaml
generated
1373
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user