").text("Error parsing content. Please check console.error() for more details."));
+ }
+ }
else {
$content.text("Preview isn't available for this note type.");
}
diff --git a/src/public/app/entities/note_short.js b/src/public/app/entities/note_short.js
index 2c403080d..13bae5a93 100644
--- a/src/public/app/entities/note_short.js
+++ b/src/public/app/entities/note_short.js
@@ -16,7 +16,8 @@ const NOTE_TYPE_ICONS = {
"relation-map": "bx bx-map-alt",
"book": "bx bx-book",
"note-map": "bx bx-map-alt",
- "mermaid": "bx bx-selection"
+ "mermaid": "bx bx-selection",
+ "canvas": "bx bx-pen"
};
/**
diff --git a/src/public/app/services/frontend_script_api.js b/src/public/app/services/frontend_script_api.js
index 257fee0df..dee3b7a7d 100644
--- a/src/public/app/services/frontend_script_api.js
+++ b/src/public/app/services/frontend_script_api.js
@@ -299,6 +299,24 @@ function FrontendScriptApi(startNote, currentNote, originEntity = null, $contain
*/
this.showError = toastService.showError;
+ /**
+ * Trigger command.
+ *
+ * @method
+ * @param {string} name
+ * @param {object} data
+ */
+ this.triggerCommand = (name, data) => appContext.triggerCommand(name, data);
+
+ /**
+ * Trigger event.
+ *
+ * @method
+ * @param {string} name
+ * @param {object} data
+ */
+ this.triggerEvent = (name, data) => appContext.triggerEvent(name, data);
+
/**
* @method
* @deprecated - this is now no-op since all the changes should be gracefully handled per widget
diff --git a/src/public/app/services/library_loader.js b/src/public/app/services/library_loader.js
index b6f0dbc8e..a80ac1cbf 100644
--- a/src/public/app/services/library_loader.js
+++ b/src/public/app/services/library_loader.js
@@ -56,6 +56,17 @@ const MERMAID = {
js: [ "libraries/mermaid.min.js" ]
}
+const EXCALIDRAW = {
+ js: [
+ "node_modules/react/umd/react.production.min.js",
+ "node_modules/react-dom/umd/react-dom.production.min.js",
+ "node_modules/@excalidraw/excalidraw/dist/excalidraw.production.min.js",
+ ],
+ // css: [
+ // "stylesheets/somestyle.css"
+ // ]
+};
+
async function requireLibrary(library) {
if (library.css) {
library.css.map(cssUrl => requireCss(cssUrl));
@@ -106,5 +117,6 @@ export default {
KATEX,
WHEEL_ZOOM,
FORCE_GRAPH,
- MERMAID
+ MERMAID,
+ EXCALIDRAW
}
diff --git a/src/public/app/services/note_content_renderer.js b/src/public/app/services/note_content_renderer.js
index f7c970f1b..771f860b7 100644
--- a/src/public/app/services/note_content_renderer.js
+++ b/src/public/app/services/note_content_renderer.js
@@ -141,6 +141,27 @@ async function getRenderedContent(note, options = {}) {
$renderedContent.append($content);
}
+ else if (type === 'canvas') {
+ // make sure surrounding container has size of what is visible. Then image is shrinked to its boundaries
+ $renderedContent.css({height: "100%", width:"100%"});
+
+ const noteComplement = await froca.getNoteComplement(note.noteId);
+ const content = noteComplement.content || "";
+
+ try {
+ const placeHolderSVG = "
";
+ const data = JSON.parse(content)
+ const svg = data.svg || placeHolderSVG;
+ /**
+ * maxWidth: size down to 100% (full) width of container but do not enlarge!
+ * height:auto to ensure that height scales with width
+ */
+ $renderedContent.append($(svg).css({maxWidth: "100%", maxHeight: "100%", height: "auto", width: "auto"}));
+ } catch(err) {
+ console.error("error parsing content as JSON", content, err);
+ $renderedContent.append($("
").text("Error parsing content. Please check console.error() for more details."));
+ }
+ }
else if (!options.tooltip && type === 'protected-session') {
const $button = $(`
Enter protected session`)
.on('click', protectedSessionService.enterProtectedSession);
diff --git a/src/public/app/services/note_list_renderer.js b/src/public/app/services/note_list_renderer.js
index 22e593470..705a0d31f 100644
--- a/src/public/app/services/note_list_renderer.js
+++ b/src/public/app/services/note_list_renderer.js
@@ -99,13 +99,15 @@ const TPL = `
padding: 10px;
}
- .note-book-content.type-image img {
+ .note-book-content.type-image img, .note-book-content.type-canvas svg {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
- .note-book-card.type-image .note-book-content img, .note-book-card.type-text .note-book-content img {
+ .note-book-card.type-image .note-book-content img,
+ .note-book-card.type-text .note-book-content img,
+ .note-book-card.type-canvas .note-book-content img {
max-width: 100%;
max-height: 100%;
}
diff --git a/src/public/app/services/protected_session_holder.js b/src/public/app/services/protected_session_holder.js
index 86ff20b2b..8fdae303c 100644
--- a/src/public/app/services/protected_session_holder.js
+++ b/src/public/app/services/protected_session_holder.js
@@ -1,17 +1,5 @@
-import options from './options.js';
import server from "./server.js";
-let lastProtectedSessionOperationDate = 0;
-
-setInterval(() => {
- const protectedSessionTimeout = options.getInt('protectedSessionTimeout');
- if (lastProtectedSessionOperationDate
- && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) {
-
- resetProtectedSession();
- }
-}, 10000);
-
function enableProtectedSession() {
glob.isProtectedSessionAvailable = true;
@@ -26,9 +14,9 @@ function isProtectedSessionAvailable() {
return glob.isProtectedSessionAvailable;
}
-function touchProtectedSession() {
+async function touchProtectedSession() {
if (isProtectedSessionAvailable()) {
- lastProtectedSessionOperationDate = Date.now();
+ await server.post("login/protected/touch");
}
}
diff --git a/src/public/app/services/tree_context_menu.js b/src/public/app/services/tree_context_menu.js
index f22de134b..89d860804 100644
--- a/src/public/app/services/tree_context_menu.js
+++ b/src/public/app/services/tree_context_menu.js
@@ -33,7 +33,8 @@ class TreeContextMenu {
{ title: "Note Map", command: command, type: "note-map", uiIcon: "map-alt" },
{ title: "Render HTML note", command: command, type: "render", uiIcon: "extension" },
{ title: "Book", command: command, type: "book", uiIcon: "book" },
- { title: "Mermaid diagram", command: command, type: "mermaid", uiIcon: "selection" }
+ { title: "Mermaid diagram", command: command, type: "mermaid", uiIcon: "selection" },
+ { title: "Canvas", command: command, type: "canvas", uiIcon: "pen" },
];
}
diff --git a/src/public/app/services/utils.js b/src/public/app/services/utils.js
index 213787b10..9facea6f8 100644
--- a/src/public/app/services/utils.js
+++ b/src/public/app/services/utils.js
@@ -359,6 +359,12 @@ function isValidAttributeName(name) {
return ATTR_NAME_MATCHER.test(name);
}
+function sleep(time_ms) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, time_ms);
+ });
+}
+
export default {
reloadFrontendApp,
parseDate,
@@ -402,5 +408,6 @@ export default {
initHelpButtons,
openHelp,
filterAttributeName,
- isValidAttributeName
+ isValidAttributeName,
+ sleep,
};
diff --git a/src/public/app/widgets/note_detail.js b/src/public/app/widgets/note_detail.js
index ef1d0fd42..0a0790fa8 100644
--- a/src/public/app/widgets/note_detail.js
+++ b/src/public/app/widgets/note_detail.js
@@ -10,6 +10,7 @@ import FileTypeWidget from "./type_widgets/file.js";
import ImageTypeWidget from "./type_widgets/image.js";
import RenderTypeWidget from "./type_widgets/render.js";
import RelationMapTypeWidget from "./type_widgets/relation_map.js";
+import CanvasTypeWidget from "./type_widgets/canvas.js";
import ProtectedSessionTypeWidget from "./type_widgets/protected_session.js";
import BookTypeWidget from "./type_widgets/book.js";
import appContext from "../services/app_context.js";
@@ -50,6 +51,7 @@ const typeWidgetClasses = {
'search': NoneTypeWidget,
'render': RenderTypeWidget,
'relation-map': RelationMapTypeWidget,
+ 'canvas': CanvasTypeWidget,
'protected-session': ProtectedSessionTypeWidget,
'book': BookTypeWidget,
'note-map': NoteMapTypeWidget
@@ -66,7 +68,7 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
const {noteId} = note;
const dto = note.dto;
- dto.content = this.getTypeWidget().getContent();
+ dto.content = await this.getTypeWidget().getContent();
// for read only notes
if (dto.content === undefined) {
@@ -145,11 +147,14 @@ export default class NoteDetailWidget extends NoteContextAwareWidget {
this.checkFullHeight();
}
+ /**
+ * sets full height of container that contains note content for a subset of note-types
+ */
checkFullHeight() {
// https://github.com/zadam/trilium/issues/2522
this.$widget.toggleClass("full-height",
!this.noteContext.hasNoteList()
- && ['editable-text', 'editable-code'].includes(this.type)
+ && ['editable-text', 'editable-code', 'canvas'].includes(this.type)
&& this.mime !== 'text/x-sqlite;schema=trilium');
}
diff --git a/src/public/app/widgets/note_type.js b/src/public/app/widgets/note_type.js
index a81e446e3..e3206ebc0 100644
--- a/src/public/app/widgets/note_type.js
+++ b/src/public/app/widgets/note_type.js
@@ -11,6 +11,7 @@ const NOTE_TYPES = [
{ type: "text", mime: "text/html", title: "Text", selectable: true },
{ type: "relation-map", mime: "application/json", title: "Relation Map", selectable: true },
{ type: "render", mime: '', title: "Render Note", selectable: true },
+ { type: "canvas", mime: 'application/json', title: "Canvas", selectable: true },
{ type: "book", mime: '', title: "Book", selectable: true },
{ type: "mermaid", mime: 'text/mermaid', title: "Mermaid Diagram", selectable: true },
{ type: "code", mime: 'text/plain', title: "Code", selectable: true }
diff --git a/src/public/app/widgets/note_wrapper.js b/src/public/app/widgets/note_wrapper.js
index 6de26926e..227543c65 100644
--- a/src/public/app/widgets/note_wrapper.js
+++ b/src/public/app/widgets/note_wrapper.js
@@ -32,7 +32,7 @@ export default class NoteWrapperWidget extends FlexContainer {
refresh(noteContext) {
this.$widget.toggleClass("full-content-width",
- ['image', 'mermaid', 'book', 'render'].includes(noteContext?.note?.type)
+ ['image', 'mermaid', 'book', 'render', 'canvas'].includes(noteContext?.note?.type)
|| !!noteContext?.note?.hasLabel('fullContentWidth')
);
}
diff --git a/src/public/app/widgets/type_widgets/canvas.js b/src/public/app/widgets/type_widgets/canvas.js
new file mode 100644
index 000000000..483baa572
--- /dev/null
+++ b/src/public/app/widgets/type_widgets/canvas.js
@@ -0,0 +1,477 @@
+import libraryLoader from "../../services/library_loader.js";
+import TypeWidget from "./type_widget.js";
+import utils from '../../services/utils.js';
+import froca from "../../services/froca.js";
+import debounce from "../../../../../libraries/lodash.debounce.js";
+
+const {sleep} = utils;
+
+const TPL = `
+
+`;
+
+/**
+ * # Canvas note with excalidraw
+ * @author thfrei 2022-05-11
+ *
+ * Background:
+ * excalidraw gives great support for hand drawn notes. It also allows to include images and support
+ * for sketching. Excalidraw has a vibrant and active community.
+ *
+ * Functionality:
+ * We store the excalidraw assets (elements, appState, files) in the note. In addition to that, we
+ * export the SVG from the canvas on every update. The SVG is also saved in the note. It is used
+ * for displaying any canvas note inside of a text note as an image.
+ *
+ * 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 node-canvas library
+ * used by jsdom. (See Trilium PR for samples and other issues in 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 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 feature, excalidraw already
+ * has.
+ *
+ * Known issues:
+ * - v0.11.0 of excalidraw does not render freedraw backgrounds in the svg
+ * - 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:
+ * - 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 bandwith. 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
+ * - Support for excalidraw libraries. Maybe special code notes with a tag.
+ */
+export default class ExcalidrawTypeWidget extends TypeWidget {
+ constructor() {
+ super();
+
+ // constants
+ this.SCENE_VERSION_INITIAL = -1;
+ this.SCENE_VERSION_ERROR = -2;
+
+ // config
+ this.DEBOUNCE_TIME_ONCHANGEHANDLER = 750; // ms
+ // ensure that assets are loaded from trilium
+ window.EXCALIDRAW_ASSET_PATH = `${window.location.origin}/node_modules/@excalidraw/excalidraw/dist/`;
+
+ // temporary vars
+ this.currentNoteId = "";
+ this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
+
+ // will be overwritten
+ this.excalidrawRef;
+ this.$render;
+ this.renderElement;
+ this.$widget;
+ this.reactHandlers; // used to control react state
+
+ this.createExcalidrawReactApp = this.createExcalidrawReactApp.bind(this);
+ this.onChangeHandler = this.onChangeHandler.bind(this);
+ this.isNewSceneVersion = this.isNewSceneVersion.bind(this);
+ }
+
+ /**
+ * (trilium)
+ * @returns {string} "canvas"
+ */
+ static getType() {
+ return "canvas";
+ }
+
+ /**
+ * (trilium)
+ * renders note
+ */
+ doRender() {
+ this.$widget = $(TPL);
+
+ this.$widget.toggleClass("full-height", true); // only add
+ this.$render = this.$widget.find('.canvas-render');
+ this.renderElement = this.$render.get(0);
+
+ libraryLoader
+ .requireLibrary(libraryLoader.EXCALIDRAW)
+ .then(() => {
+ const React = window.React;
+ const ReactDOM = window.ReactDOM;
+
+ ReactDOM.unmountComponentAtNode(this.renderElement);
+ ReactDOM.render(React.createElement(this.createExcalidrawReactApp), this.renderElement);
+ })
+
+ return this.$widget;
+ }
+
+ /**
+ * (trilium)
+ * called to populate the widget container with the note content
+ *
+ * @param {note} note
+ */
+ async doRefresh(note) {
+ // see if note changed, since we do not get a new class for a new note
+ const noteChanged = this.currentNoteId !== note.noteId;
+ if (noteChanged) {
+ // reset scene to omit unnecessary onchange handler
+ this.currentSceneVersion = this.SCENE_VERSION_INITIAL;
+ }
+ this.currentNoteId = note.noteId;
+
+ // get note from backend and put into canvas
+ const noteComplement = await froca.getNoteComplement(note.noteId);
+
+ // before we load content into excalidraw, make sure excalidraw has loaded
+ while (!this.excalidrawRef || !this.excalidrawRef.current) {
+ this.log("excalidrawRef not yet loeaded, sleep 200ms...");
+ await sleep(200);
+ }
+
+ /**
+ * 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 (this.excalidrawRef.current && noteComplement.content === "") {
+ const sceneData = {
+ elements: [],
+ appState: {},
+ collaborators: []
+ };
+
+ this.excalidrawRef.current.updateScene(sceneData);
+ }
+
+ /**
+ * load saved content into excalidraw canvas
+ */
+ else if (this.excalidrawRef.current && noteComplement.content) {
+ let content ={
+ elements: [],
+ appState: [],
+ files: [],
+ };
+
+ try {
+ content = JSON.parse(noteComplement.content || "");
+ } catch(err) {
+ console.error("Error parsing content. Probably note.type changed",
+ "Starting with empty canvas"
+ , note, noteComplement, err);
+ }
+
+ const {elements, appState, files} = content;
+
+ /**
+ * use widths and offsets of current view, since stored appState has the state from
+ * previous edit. using the stored state would lead to pointer mismatch.
+ */
+ const boundingClientRect = this.excalidrawWrapperRef.current.getBoundingClientRect();
+ appState.width = boundingClientRect.width;
+ appState.height = boundingClientRect.height;
+ appState.offsetLeft = boundingClientRect.left;
+ appState.offsetTop = boundingClientRect.top;
+
+ const sceneData = {
+ elements,
+ appState,
+ collaborators: []
+ };
+
+ // files are expected in an array when loading. they are stored as an key-index object
+ // see example for loading here:
+ // https://github.com/excalidraw/excalidraw/blob/c5a7723185f6ca05e0ceb0b0d45c4e3fbcb81b2a/src/packages/excalidraw/example/App.js#L68
+ const fileArray = [];
+ 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);
+ }
+
+ this.sceneVersion = window.Excalidraw.getSceneVersion(elements);
+
+ this.excalidrawRef.current.updateScene(sceneData);
+ this.excalidrawRef.current.addFiles(fileArray);
+ }
+
+ // set initial scene version
+ if (this.currentSceneVersion === this.SCENE_VERSION_INITIAL) {
+ this.currentSceneVersion = this.getSceneVersion();
+ }
+ }
+
+ /**
+ * (trilium)
+ * gets data from widget container that will be sent via spacedUpdate.scheduleUpdate();
+ * this is automatically called after this.saveData();
+ */
+ async getContent() {
+ const elements = this.excalidrawRef.current.getSceneElements();
+ const appState = this.excalidrawRef.current.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 new excalidraw version?
+ */
+ const files = this.excalidrawRef.current.getFiles();
+
+ /**
+ * parallel svg export to combat bitrot and enable rendering image for note inclusion,
+ * preview and share.
+ */
+ const svg = await window.Excalidraw.exportToSvg({
+ elements,
+ appState,
+ exportPadding: 5, // 5 px padding
+ metadata: 'trilium-export',
+ files
+ });
+ const svgString = svg.outerHTML;
+
+ /**
+ * workaround until https://github.com/excalidraw/excalidraw/pull/5065 is merged and published
+ */
+ const svgSafeString = this.replaceExternalAssets(svgString);
+
+ const activeFiles = {};
+ elements.forEach((element) => {
+ if (element.fileId) {
+ activeFiles[element.fileId] = files[element.fileId];
+ }
+ })
+
+ const content = {
+ _meta: "This note has type `canvas`. It uses excalidraw and stores an exported svg alongside.",
+ elements, // excalidraw
+ appState, // excalidraw
+ files: activeFiles, // excalidraw
+ svg: svgSafeString, // not needed for excalidraw, used for note_short, content, and image api
+ };
+
+ const contentString = JSON.stringify(content);
+
+ return contentString;
+ }
+
+ /**
+ * (trilium)
+ * save content to backend
+ * spacedUpdate is kind of a debouncer.
+ */
+ saveData() {
+ this.spacedUpdate.scheduleUpdate();
+ }
+
+ onChangeHandler() {
+ const appState = this.excalidrawRef.current.getAppState() || {};
+
+ // 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.
+ const isNewSceneVersion = this.isNewSceneVersion();
+ /**
+ * 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
+ */
+
+ // upon updateScene, onchange is called, even though "nothing really changed" that is worth saving
+ const isNotInitialScene = this.currentSceneVersion !== this.SCENE_VERSION_INITIAL;
+
+ const shouldSave = isNewSceneVersion && isNotInitialScene;
+
+ if (shouldSave) {
+ this.updateSceneVersion();
+ this.saveData();
+ } else {
+ // do nothing
+ }
+ }
+
+ createExcalidrawReactApp() {
+ const React = window.React;
+ const Excalidraw = window.Excalidraw;
+
+ const excalidrawRef = React.useRef(null);
+ this.excalidrawRef = excalidrawRef;
+ const excalidrawWrapperRef = React.useRef(null);
+ this.excalidrawWrapperRef = excalidrawWrapperRef;
+ const [dimensions, setDimensions] = React.useState({
+ width: undefined,
+ height: undefined
+ });
+
+ const [viewModeEnabled, setViewModeEnabled] = React.useState(false);
+ const [zenModeEnabled, setZenModeEnabled] = React.useState(false);
+ const [gridModeEnabled, setGridModeEnabled] = React.useState(false);
+ const [synchronized, setSynchronized] = React.useState(true);
+
+ React.useEffect(() => {
+ const dimensions = {
+ width: excalidrawWrapperRef.current.getBoundingClientRect().width,
+ height: excalidrawWrapperRef.current.getBoundingClientRect().height
+ };
+ setDimensions(dimensions);
+
+ const onResize = () => {
+ 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((element, event) => {
+ const link = element.link;
+ const { nativeEvent } = event.detail;
+ const isNewTab = nativeEvent.ctrlKey || nativeEvent.metaKey;
+ const isNewWindow = nativeEvent.shiftKey;
+ const isInternalLink = link.startsWith("/")
+ || link.includes(window.location.origin);
+
+ if (isInternalLink && !isNewTab && !isNewWindow) {
+ // signal that we're handling the redirect ourselves
+ event.preventDefault();
+ // do a custom redirect, such as passing to react-router
+ // ...
+ } else {
+ // open in same tab
+ }
+ }, []);
+
+ return React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(
+ "div",
+ {
+ className: "excalidraw-wrapper",
+ ref: excalidrawWrapperRef
+ },
+ React.createElement(Excalidraw.default, {
+ ref: excalidrawRef,
+ width: dimensions.width,
+ height: dimensions.height,
+ // initialData: InitialData,
+ onPaste: (data, event) => {
+ this.log("Verbose: excalidraw internal paste. No trilium action implemented.", data, event);
+ },
+ onChange: debounce(this.onChangeHandler, this.DEBOUNCE_TIME_ONCHANGEHANDLER),
+ // onPointerUpdate: (payload) => console.log(payload),
+ onCollabButtonClick: () => {
+ window.alert("You clicked on collab button. No collaboration is implemented.");
+ },
+ viewModeEnabled: viewModeEnabled,
+ zenModeEnabled: zenModeEnabled,
+ gridModeEnabled: gridModeEnabled,
+ isCollaborating: false,
+ detectScroll: false,
+ handleKeyboardGlobally: false,
+ autoFocus: true,
+ onLinkOpen,
+ })
+ )
+ );
+ }
+
+ /**
+ * needed to ensure, that multipleOnChangeHandler calls do not trigger a safe.
+ * 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 === this.SCENE_VERSION_INITIAL // initial scene version update
+ || this.currentSceneVersion !== sceneVersion // ensure scene changed
+ ;
+ }
+
+ getSceneVersion() {
+ if (this.excalidrawRef) {
+ const elements = this.excalidrawRef.current.getSceneElements();
+ const sceneVersion = window.Excalidraw.getSceneVersion(elements);
+ return sceneVersion;
+ } else {
+ return this.SCENE_VERSION_ERROR;
+ }
+ }
+
+ updateSceneVersion() {
+ this.currentSceneVersion = this.getSceneVersion();
+ }
+
+ /**
+ * logs to console.log with some predefined title
+ *
+ * @param {...any} args
+ */
+ log(...args) {
+ let title = '';
+ if (this.note) {
+ title = this.note.title;
+ } else {
+ title = this.noteId + "nt/na";
+ }
+
+ console.log(title, "=", this.noteId, "==", ...args);
+ }
+
+ /**
+ * replaces exlicraw.com with own assets
+ *
+ * workaround until https://github.com/excalidraw/excalidraw/pull/5065 is merged and published
+ * needed for v0.11.0
+ *
+ * @param {string} string
+ * @returns
+ */
+ replaceExternalAssets = (string) => {
+ let result = string;
+ // exlidraw.com asset in react usage
+ result = result.replaceAll("https://excalidraw.com/", window.EXCALIDRAW_ASSET_PATH+"excalidraw-assets/");
+ return result;
+ }
+}
diff --git a/src/public/app/widgets/type_widgets/type_widget.js b/src/public/app/widgets/type_widgets/type_widget.js
index 49aa2212d..f3dd88317 100644
--- a/src/public/app/widgets/type_widgets/type_widget.js
+++ b/src/public/app/widgets/type_widgets/type_widget.js
@@ -38,6 +38,9 @@ export default class TypeWidget extends NoteContextAwareWidget {
return this.$widget.is(":visible") && this.noteContext?.ntxId === appContext.tabManager.activeNtxId;
}
+ /**
+ * @returns {Promise|*} promise resolving content or directly the content
+ */
getContent() {}
focus() {}
diff --git a/src/routes/api/image.js b/src/routes/api/image.js
index 379eaf65f..7807f1fb9 100644
--- a/src/routes/api/image.js
+++ b/src/routes/api/image.js
@@ -11,7 +11,7 @@ function returnImage(req, res) {
if (!image) {
return res.sendStatus(404);
}
- else if (image.type !== 'image') {
+ else if (!["image", "canvas"].includes(image.type)){
return res.sendStatus(400);
}
else if (image.isDeleted || image.data === null) {
@@ -19,10 +19,27 @@ function returnImage(req, res) {
return res.send(fs.readFileSync(RESOURCE_DIR + '/db/image-deleted.png'));
}
- res.set('Content-Type', image.mime);
- res.set("Cache-Control", "no-cache, no-store, must-revalidate");
-
- res.send(image.getContent());
+ /**
+ * special "image" type. the canvas is actually type application/json
+ * to avoid bitrot and enable usage as referenced image the svg is included.
+ */
+ if (image.type === 'canvas') {
+ const content = image.getContent();
+ try {
+ const data = JSON.parse(content);
+
+ const svg = data.svg || '
'
+ res.set('Content-Type', "image/svg+xml");
+ res.set("Cache-Control", "no-cache, no-store, must-revalidate");
+ res.send(svg);
+ } catch(err) {
+ res.status(500).send("there was an error parsing excalidraw to svg");
+ }
+ } else {
+ res.set('Content-Type', image.mime);
+ res.set("Cache-Control", "no-cache, no-store, must-revalidate");
+ res.send(image.getContent());
+ }
}
function uploadImage(req) {
diff --git a/src/routes/api/login.js b/src/routes/api/login.js
index fa8685d8a..89d3ec4d7 100644
--- a/src/routes/api/login.js
+++ b/src/routes/api/login.js
@@ -83,6 +83,10 @@ function logoutFromProtectedSession() {
ws.sendMessageToAllClients({ type: 'protectedSessionLogout' });
}
+function touchProtectedSession() {
+ protectedSessionService.touchProtectedSession();
+}
+
function token(req) {
const password = req.body.password;
@@ -92,7 +96,7 @@ function token(req) {
// for backwards compatibility with Sender which does not send the name
const tokenName = req.body.tokenName || "Trilium Sender / Web Clipper";
-
+
const {authToken} = etapiTokenService.createToken(tokenName);
return { token: authToken };
@@ -102,5 +106,6 @@ module.exports = {
loginSync,
loginToProtectedSession,
logoutFromProtectedSession,
+ touchProtectedSession,
token
};
diff --git a/src/routes/routes.js b/src/routes/routes.js
index f899e4f7d..b51f1a521 100644
--- a/src/routes/routes.js
+++ b/src/routes/routes.js
@@ -285,6 +285,7 @@ function register(app) {
apiRoute(POST, '/api/special-notes/search-note', specialNotesRoute.createSearchNote);
apiRoute(POST, '/api/special-notes/save-search-note', specialNotesRoute.saveSearchNote);
+ // :filename is not used by trilium, but instead used for "save as" to assign a human readable filename
route(GET, '/api/images/:noteId/:filename', [auth.checkApiAuthOrElectron], imageRoute.returnImage);
route(POST, '/api/images', [auth.checkApiAuthOrElectron, uploadMiddleware, csrfMiddleware], imageRoute.uploadImage, apiResultHandler);
route(PUT, '/api/images/:noteId', [auth.checkApiAuthOrElectron, uploadMiddleware, csrfMiddleware], imageRoute.updateImage, apiResultHandler);
@@ -358,6 +359,7 @@ function register(app) {
route(POST, '/api/login/sync', [], loginApiRoute.loginSync, apiResultHandler);
// this is for entering protected mode so user has to be already logged-in (that's the reason we don't require username)
apiRoute(POST, '/api/login/protected', loginApiRoute.loginToProtectedSession);
+ apiRoute(POST, '/api/login/protected/touch', loginApiRoute.touchProtectedSession);
apiRoute(POST, '/api/logout/protected', loginApiRoute.logoutFromProtectedSession);
route(POST, '/api/login/token', [], loginApiRoute.token, apiResultHandler);
diff --git a/src/services/backend_script_api.js b/src/services/backend_script_api.js
index 757c067bb..dadc40c6a 100644
--- a/src/services/backend_script_api.js
+++ b/src/services/backend_script_api.js
@@ -210,7 +210,7 @@ function BackendScriptApi(currentNote, apiParams) {
* @property {string} parentNoteId - MANDATORY
* @property {string} title - MANDATORY
* @property {string|buffer} content - MANDATORY
- * @property {string} type - text, code, file, image, search, book, relation-map - MANDATORY
+ * @property {string} type - text, code, file, image, search, book, relation-map, canvas - MANDATORY
* @property {string} mime - value is derived from default mimes for type
* @property {boolean} isProtected - default is false
* @property {boolean} isExpanded - default is false
diff --git a/src/services/entity_changes.js b/src/services/entity_changes.js
index 209e0c7f5..62eabc944 100644
--- a/src/services/entity_changes.js
+++ b/src/services/entity_changes.js
@@ -135,7 +135,6 @@ function fillAllEntityChanges() {
fillEntityChanges("branches", "branchId");
fillEntityChanges("note_revisions", "noteRevisionId");
fillEntityChanges("note_revision_contents", "noteRevisionId");
- fillEntityChanges("recent_notes", "noteId");
fillEntityChanges("attributes", "attributeId");
fillEntityChanges("etapi_tokens", "etapiTokenId");
fillEntityChanges("options", "name", 'isSynced = 1');
diff --git a/src/services/export/single.js b/src/services/export/single.js
index 2937b40d7..881bb394b 100644
--- a/src/services/export/single.js
+++ b/src/services/export/single.js
@@ -41,7 +41,7 @@ function exportSingleNote(taskContext, branch, format, res) {
extension = mimeTypes.extension(note.mime) || 'code';
mime = note.mime;
}
- else if (note.type === 'relation-map' || note.type === 'search') {
+ else if (note.type === 'relation-map' || note.type === 'canvas' || note.type === 'search') {
payload = content;
extension = 'json';
mime = 'application/json';
diff --git a/src/services/note_types.js b/src/services/note_types.js
index 05a242c94..cdc890162 100644
--- a/src/services/note_types.js
+++ b/src/services/note_types.js
@@ -7,6 +7,7 @@ module.exports = [
'search',
'relation-map',
'book',
- 'note-map',
- 'mermaid'
+ 'note-map',
+ 'mermaid',
+ 'canvas'
];
\ No newline at end of file
diff --git a/src/services/notes.js b/src/services/notes.js
index 86344061b..9accb08d8 100644
--- a/src/services/notes.js
+++ b/src/services/notes.js
@@ -53,7 +53,7 @@ function deriveMime(type, mime) {
mime = 'text/html';
} else if (type === 'code' || type === 'mermaid') {
mime = 'text/plain';
- } else if (['relation-map', 'search'].includes(type)) {
+ } else if (['relation-map', 'search', 'canvas'].includes(type)) {
mime = 'application/json';
} else if (['render', 'book'].includes(type)) {
mime = '';
@@ -84,7 +84,7 @@ function copyChildAttributes(parentNote, childNote) {
* - {string} parentNoteId
* - {string} title
* - {*} content
- * - {string} type - text, code, file, image, search, book, relation-map, render
+ * - {string} type - text, code, file, image, search, book, relation-map, canvas, render
*
* Following are optional (have defaults)
* - {string} mime - value is derived from default mimes for type
diff --git a/src/services/protected_session.js b/src/services/protected_session.js
index 7dfddc2df..ad125deca 100644
--- a/src/services/protected_session.js
+++ b/src/services/protected_session.js
@@ -2,6 +2,7 @@
const log = require('./log');
const dataEncryptionService = require('./data_encryption');
+const options = require("./options");
let dataKey = null;
@@ -54,6 +55,27 @@ function decryptString(cipherText) {
return dataEncryptionService.decryptString(getDataKey(), cipherText);
}
+let lastProtectedSessionOperationDate = null;
+
+function touchProtectedSession() {
+ if (isProtectedSessionAvailable()) {
+ lastProtectedSessionOperationDate = Date.now();
+ }
+}
+
+setInterval(() => {
+ const protectedSessionTimeout = options.getOptionInt('protectedSessionTimeout');
+ if (isProtectedSessionAvailable()
+ && lastProtectedSessionOperationDate
+ && Date.now() - lastProtectedSessionOperationDate > protectedSessionTimeout * 1000) {
+
+ resetDataKey();
+
+ require('./ws').reloadFrontend();
+ }
+}, 30000);
+
+
module.exports = {
setDataKey,
resetDataKey,
@@ -61,5 +83,6 @@ module.exports = {
encrypt,
decrypt,
decryptString,
- decryptNotes
+ decryptNotes,
+ touchProtectedSession
};
diff --git a/src/services/search/expressions/note_content_fulltext.js b/src/services/search/expressions/note_content_fulltext.js
new file mode 100644
index 000000000..548578c2b
--- /dev/null
+++ b/src/services/search/expressions/note_content_fulltext.js
@@ -0,0 +1,116 @@
+"use strict";
+
+const Expression = require('./expression');
+const NoteSet = require('../note_set');
+const log = require('../../log');
+const becca = require('../../../becca/becca');
+const protectedSessionService = require('../../protected_session');
+const striptags = require('striptags');
+const utils = require("../../utils");
+
+const ALLOWED_OPERATORS = ['*=*', '=', '*=', '=*', '%='];
+
+const cachedRegexes = {};
+
+function getRegex(str) {
+ if (!(str in cachedRegexes)) {
+ cachedRegexes[str] = new RegExp(str, 'ms'); // multiline, dot-all
+ }
+
+ return cachedRegexes[str];
+}
+
+class NoteContentFulltextExp extends Expression {
+ constructor(operator, {tokens, raw, flatText}) {
+ super();
+
+ if (!ALLOWED_OPERATORS.includes(operator)) {
+ throw new Error(`Note content can be searched only with operators: ` + ALLOWED_OPERATORS.join(", ") + `, operator ${operator} given.`);
+ }
+
+ this.operator = operator;
+ this.tokens = tokens;
+ this.raw = !!raw;
+ this.flatText = !!flatText;
+ }
+
+ execute(inputNoteSet) {
+ const resultNoteSet = new NoteSet();
+ const sql = require('../../sql');
+
+ for (let {noteId, type, mime, content, isProtected} of sql.iterateRows(`
+ SELECT noteId, type, mime, content, isProtected
+ FROM notes JOIN note_contents USING (noteId)
+ WHERE type IN ('text', 'code', 'mermaid') AND isDeleted = 0`)) {
+
+ if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
+ continue;
+ }
+
+ if (isProtected) {
+ if (!protectedSessionService.isProtectedSessionAvailable()) {
+ continue;
+ }
+
+ try {
+ content = protectedSessionService.decryptString(content);
+ } catch (e) {
+ log.info(`Cannot decrypt content of note ${noteId}`);
+ continue;
+ }
+ }
+
+ content = this.preprocessContent(content, type, mime);
+
+ if (this.tokens.length === 1) {
+ const [token] = this.tokens;
+
+ if ((this.operator === '=' && token === content)
+ || (this.operator === '*=' && content.endsWith(token))
+ || (this.operator === '=*' && content.startsWith(token))
+ || (this.operator === '*=*' && content.includes(token))
+ || (this.operator === '%=' && getRegex(token).test(content))) {
+
+ resultNoteSet.add(becca.notes[noteId]);
+ }
+ }
+ else {
+ const nonMatchingToken = this.tokens.find(token =>
+ !content.includes(token) &&
+ (
+ // in case of default fulltext search we should consider both title, attrs and content
+ // so e.g. "hello world" should match when "hello" is in title and "world" in content
+ !this.flatText
+ || !becca.notes[noteId].getFlatText().includes(token)
+ )
+ );
+
+ if (!nonMatchingToken) {
+ resultNoteSet.add(becca.notes[noteId]);
+ }
+ }
+ }
+
+ return resultNoteSet;
+ }
+
+ preprocessContent(content, type, mime) {
+ content = utils.normalize(content.toString());
+
+ if (type === 'text' && mime === 'text/html') {
+ if (!this.raw && content.length < 20000) { // striptags is slow for very large notes
+ // allow link to preserve URLs: https://github.com/zadam/trilium/issues/2412
+ content = striptags(content, ['a']);
+
+ // at least the closing tag can be easily stripped
+ content = content.replace(/<\/a>/ig, "");
+ }
+
+ content = content.replace(/ /g, ' ');
+ }
+
+ return content.trim();
+ }
+}
+
+module.exports = NoteContentFulltextExp;
diff --git a/src/services/search/expressions/note_content_protected_fulltext.js b/src/services/search/expressions/note_content_protected_fulltext.js
deleted file mode 100644
index 05130e087..000000000
--- a/src/services/search/expressions/note_content_protected_fulltext.js
+++ /dev/null
@@ -1,89 +0,0 @@
-"use strict";
-
-const Expression = require('./expression');
-const NoteSet = require('../note_set');
-const log = require('../../log');
-const becca = require('../../../becca/becca');
-const protectedSessionService = require('../../protected_session');
-const striptags = require('striptags');
-const utils = require("../../utils");
-
-// FIXME: create common subclass with NoteContentUnprotectedFulltextExp to avoid duplication
-class NoteContentProtectedFulltextExp extends Expression {
- constructor(operator, {tokens, raw, flatText}) {
- super();
-
- if (operator !== '*=*') {
- throw new Error(`Note content can be searched only with *=* operator`);
- }
-
- this.tokens = tokens;
- this.raw = !!raw;
- this.flatText = !!flatText;
- }
-
- execute(inputNoteSet) {
- const resultNoteSet = new NoteSet();
-
- if (!protectedSessionService.isProtectedSessionAvailable()) {
- return resultNoteSet;
- }
-
- const sql = require('../../sql');
-
- for (let {noteId, type, mime, content} of sql.iterateRows(`
- SELECT noteId, type, mime, content
- FROM notes JOIN note_contents USING (noteId)
- WHERE type IN ('text', 'code', 'mermaid') AND isDeleted = 0 AND isProtected = 1`)) {
-
- if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
- continue;
- }
-
- try {
- content = protectedSessionService.decryptString(content);
- }
- catch (e) {
- log.info(`Cannot decrypt content of note ${noteId}`);
- continue;
- }
-
- content = this.preprocessContent(content, type, mime);
-
- const nonMatchingToken = this.tokens.find(token =>
- !content.includes(token) &&
- (
- // in case of default fulltext search we should consider both title, attrs and content
- // so e.g. "hello world" should match when "hello" is in title and "world" in content
- !this.flatText
- || !becca.notes[noteId].getFlatText().includes(token)
- )
- );
-
- if (!nonMatchingToken) {
- resultNoteSet.add(becca.notes[noteId]);
- }
- }
-
- return resultNoteSet;
- }
-
- preprocessContent(content, type, mime) {
- content = utils.normalize(content.toString());
-
- if (type === 'text' && mime === 'text/html') {
- if (!this.raw && content.length < 20000) { // striptags is slow for very large notes
- // allow link to preserve URLs: https://github.com/zadam/trilium/issues/2412
- content = striptags(content, ['a']);
-
- // at least the closing tag can be easily stripped
- content = content.replace(/<\/a>/ig, "");
- }
-
- content = content.replace(/ /g, ' ');
- }
- return content;
- }
-}
-
-module.exports = NoteContentProtectedFulltextExp;
diff --git a/src/services/search/expressions/note_content_unprotected_fulltext.js b/src/services/search/expressions/note_content_unprotected_fulltext.js
deleted file mode 100644
index 7abbd0d78..000000000
--- a/src/services/search/expressions/note_content_unprotected_fulltext.js
+++ /dev/null
@@ -1,75 +0,0 @@
-"use strict";
-
-const Expression = require('./expression');
-const NoteSet = require('../note_set');
-const becca = require('../../../becca/becca');
-const striptags = require('striptags');
-const utils = require("../../utils");
-
-// FIXME: create common subclass with NoteContentProtectedFulltextExp to avoid duplication
-class NoteContentUnprotectedFulltextExp extends Expression {
- constructor(operator, {tokens, raw, flatText}) {
- super();
-
- if (operator !== '*=*') {
- throw new Error(`Note content can be searched only with *=* operator`);
- }
-
- this.tokens = tokens;
- this.raw = !!raw;
- this.flatText = !!flatText;
- }
-
- execute(inputNoteSet) {
- const resultNoteSet = new NoteSet();
-
- const sql = require('../../sql');
-
- for (let {noteId, type, mime, content} of sql.iterateRows(`
- SELECT noteId, type, mime, content
- FROM notes JOIN note_contents USING (noteId)
- WHERE type IN ('text', 'code', 'mermaid') AND isDeleted = 0 AND isProtected = 0`)) {
-
- if (!inputNoteSet.hasNoteId(noteId) || !(noteId in becca.notes)) {
- continue;
- }
-
- content = this.preprocessContent(content, type, mime);
-
- const nonMatchingToken = this.tokens.find(token =>
- !content.includes(token) &&
- (
- // in case of default fulltext search we should consider both title, attrs and content
- // so e.g. "hello world" should match when "hello" is in title and "world" in content
- !this.flatText
- || !becca.notes[noteId].getFlatText().includes(token)
- )
- );
-
- if (!nonMatchingToken) {
- resultNoteSet.add(becca.notes[noteId]);
- }
- }
-
- return resultNoteSet;
- }
-
- preprocessContent(content, type, mime) {
- content = utils.normalize(content.toString());
-
- if (type === 'text' && mime === 'text/html') {
- if (!this.raw && content.length < 20000) { // striptags is slow for very large notes
- // allow link to preserve URLs: https://github.com/zadam/trilium/issues/2412
- content = striptags(content, ['a']);
-
- // at least the closing tag can be easily stripped
- content = content.replace(/<\/a>/ig, "");
- }
-
- content = content.replace(/ /g, ' ');
- }
- return content;
- }
-}
-
-module.exports = NoteContentUnprotectedFulltextExp;
diff --git a/src/services/search/services/build_comparator.js b/src/services/search/services/build_comparator.js
index 561f3df66..6d3ba463a 100644
--- a/src/services/search/services/build_comparator.js
+++ b/src/services/search/services/build_comparator.js
@@ -1,3 +1,13 @@
+const cachedRegexes = {};
+
+function getRegex(str) {
+ if (!(str in cachedRegexes)) {
+ cachedRegexes[str] = new RegExp(str);
+ }
+
+ return cachedRegexes[str];
+}
+
const stringComparators = {
"=": comparedValue => (val => val === comparedValue),
"!=": comparedValue => (val => val !== comparedValue),
@@ -8,6 +18,7 @@ const stringComparators = {
"*=": comparedValue => (val => val && val.endsWith(comparedValue)),
"=*": comparedValue => (val => val && val.startsWith(comparedValue)),
"*=*": comparedValue => (val => val && val.includes(comparedValue)),
+ "%=": comparedValue => (val => val && !!getRegex(comparedValue).test(val)),
};
const numericComparators = {
diff --git a/src/services/search/services/lex.js b/src/services/search/services/lex.js
index 5234900f7..c6bdc2dfd 100644
--- a/src/services/search/services/lex.js
+++ b/src/services/search/services/lex.js
@@ -9,7 +9,7 @@ function lex(str) {
let currentWord = '';
function isSymbolAnOperator(chr) {
- return ['=', '*', '>', '<', '!', "-", "+"].includes(chr);
+ return ['=', '*', '>', '<', '!', "-", "+", '%'].includes(chr);
}
function isPreviousSymbolAnOperator() {
diff --git a/src/services/search/services/parse.js b/src/services/search/services/parse.js
index 9ba5ce506..8ab99fe31 100644
--- a/src/services/search/services/parse.js
+++ b/src/services/search/services/parse.js
@@ -12,8 +12,7 @@ const PropertyComparisonExp = require('../expressions/property_comparison');
const AttributeExistsExp = require('../expressions/attribute_exists');
const LabelComparisonExp = require('../expressions/label_comparison');
const NoteFlatTextExp = require('../expressions/note_flat_text');
-const NoteContentProtectedFulltextExp = require('../expressions/note_content_protected_fulltext');
-const NoteContentUnprotectedFulltextExp = require('../expressions/note_content_unprotected_fulltext');
+const NoteContentFulltextExp = require('../expressions/note_content_fulltext.js');
const OrderByAndLimitExp = require('../expressions/order_by_and_limit');
const AncestorExp = require("../expressions/ancestor");
const buildComparator = require('./build_comparator');
@@ -32,8 +31,7 @@ function getFulltext(tokens, searchContext) {
if (!searchContext.fastSearch) {
return new OrExp([
new NoteFlatTextExp(tokens),
- new NoteContentProtectedFulltextExp('*=*', {tokens, flatText: true}),
- new NoteContentUnprotectedFulltextExp('*=*', {tokens, flatText: true})
+ new NoteContentFulltextExp('*=*', {tokens, flatText: true})
]);
}
else {
@@ -42,7 +40,7 @@ function getFulltext(tokens, searchContext) {
}
function isOperator(str) {
- return str.match(/^[!=<>*]+$/);
+ return str.match(/^[!=<>*%]+$/);
}
function getExpression(tokens, searchContext, level = 0) {
@@ -140,10 +138,7 @@ function getExpression(tokens, searchContext, level = 0) {
i++;
- return new OrExp([
- new NoteContentUnprotectedFulltextExp(operator, {tokens: [tokens[i].token], raw }),
- new NoteContentProtectedFulltextExp(operator, {tokens: [tokens[i].token], raw })
- ]);
+ return new NoteContentFulltextExp(operator, {tokens: [tokens[i].token], raw });
}
if (tokens[i].token === 'parents') {
@@ -196,8 +191,7 @@ function getExpression(tokens, searchContext, level = 0) {
return new OrExp([
new PropertyComparisonExp(searchContext, 'title', '*=*', tokens[i].token),
- new NoteContentProtectedFulltextExp('*=*', {tokens: [tokens[i].token]}),
- new NoteContentUnprotectedFulltextExp('*=*', {tokens: [tokens[i].token]})
+ new NoteContentFulltextExp('*=*', {tokens: [tokens[i].token]})
]);
}
diff --git a/src/services/utils.js b/src/services/utils.js
index f4f31c69e..85b719d01 100644
--- a/src/services/utils.js
+++ b/src/services/utils.js
@@ -168,7 +168,7 @@ const STRING_MIME_TYPES = [
function isStringNote(type, mime) {
// render and book are string note in the sense that they are expected to contain empty string
- return ["text", "code", "relation-map", "search", "render", "book", "mermaid"].includes(type)
+ return ["text", "code", "relation-map", "search", "render", "book", "mermaid", "canvas"].includes(type)
|| mime.startsWith('text/')
|| STRING_MIME_TYPES.includes(mime);
}
@@ -192,7 +192,7 @@ function formatDownloadTitle(filename, type, mime) {
if (type === 'text') {
return filename + '.html';
- } else if (['relation-map', 'search'].includes(type)) {
+ } else if (['relation-map', 'canvas', 'search'].includes(type)) {
return filename + '.json';
} else {
if (!mime) {
diff --git a/src/share/canvas_share.js b/src/share/canvas_share.js
new file mode 100644
index 000000000..70da159a0
--- /dev/null
+++ b/src/share/canvas_share.js
@@ -0,0 +1,102 @@
+/**
+ * this is used as a "standalone js" file and required by a shared note directly via script-tags
+ *
+ * data input comes via window variable as follow
+ * const {elements, appState, files} = window.triliumExcalidraw;
+ */
+
+document.getElementById("excalidraw-app").style.height = appState.height+"px";
+
+const App = () => {
+ const excalidrawRef = React.useRef(null);
+ const excalidrawWrapperRef = React.useRef(null);
+ const [dimensions, setDimensions] = React.useState({
+ width: undefined,
+ height: appState.height,
+ });
+ const [viewModeEnabled, setViewModeEnabled] = React.useState(false);
+
+ // ensure that assets are loaded from trilium
+
+ /**
+ * resizing
+ */
+ React.useEffect(() => {
+ const dimensions = {
+ width: excalidrawWrapperRef.current.getBoundingClientRect().width,
+ height: excalidrawWrapperRef.current.getBoundingClientRect().height
+ };
+ setDimensions(dimensions);
+
+ const onResize = () => {
+ const dimensions = {
+ width: excalidrawWrapperRef.current.getBoundingClientRect().width,
+ height: excalidrawWrapperRef.current.getBoundingClientRect().height
+ };
+ setDimensions(dimensions);
+ };
+
+ window.addEventListener("resize", onResize);
+ // ensure that resize is also called for split creation and deletion
+ // not really the problem. problem is saved appState!
+ // self.$renderElement.addEventListener("resize", onResize);
+
+ return () => window.removeEventListener("resize", onResize);
+ }, [excalidrawWrapperRef]);
+
+ return React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(
+ "div",
+ {
+ className: "excalidraw-wrapper",
+ ref: excalidrawWrapperRef
+ },
+ React.createElement(Excalidraw.default, {
+ ref: excalidrawRef,
+ width: dimensions.width,
+ height: dimensions.height,
+ initialData: {
+ elements, appState, files
+ },
+ viewModeEnabled: !viewModeEnabled,
+ zenModeEnabled: false,
+ gridModeEnabled: false,
+ isCollaborating: false,
+ detectScroll: false,
+ handleKeyboardGlobally: false,
+ autoFocus: true,
+ renderFooter: () => {
+ return React.createElement(
+ React.Fragment,
+ null,
+ React.createElement(
+ "div",
+ {
+ className: "excalidraw-top-right-ui excalidraw Island",
+ },
+ React.createElement(
+ "label",
+ {
+ style: {
+ padding: "5px",
+ },
+ className: "excalidraw Stack",
+ },
+ React.createElement(
+ "button",
+ {
+ onClick: () => setViewModeEnabled(!viewModeEnabled)
+ },
+ viewModeEnabled ? " Enter simple view mode " : " Enter extended view mode "
+ ),
+ ""
+ ),
+ ));
+ },
+ })
+ )
+ );
+};
+ReactDOM.render(React.createElement(App), document.getElementById("excalidraw-app"));
diff --git a/src/share/content_renderer.js b/src/share/content_renderer.js
index 3ff390bc5..6584b4b23 100644
--- a/src/share/content_renderer.js
+++ b/src/share/content_renderer.js
@@ -85,6 +85,38 @@ document.addEventListener("DOMContentLoaded", function() {
else if (note.type === 'book') {
isEmpty = true;
}
+ else if (note.type === 'canvas') {
+ header += ``;
+ header += ``;
+ header += ``;
+ header += ``;
+ header += ``;
+
+ content = `
`;
+ }
else {
content = '
This note type cannot be displayed.
';
}
@@ -99,7 +131,3 @@ document.addEventListener("DOMContentLoaded", function() {
module.exports = {
getContent
};
-
-
-
-
diff --git a/src/share/routes.js b/src/share/routes.js
index 917e66d14..ce858ad6b 100644
--- a/src/share/routes.js
+++ b/src/share/routes.js
@@ -1,3 +1,6 @@
+const express = require('express');
+const path = require('path');
+
const shaca = require("./shaca/shaca");
const shacaLoader = require("./shaca/shaca_loader");
const shareRoot = require("./share_root");
@@ -55,6 +58,8 @@ function register(router) {
});
}
+ router.use('/share/canvas_share.js', express.static(path.join(__dirname, 'canvas_share.js')));
+
router.get(['/share', '/share/'], (req, res, next) => {
shacaLoader.ensureLoad();
@@ -110,6 +115,7 @@ function register(router) {
res.send(note.getContent());
});
+ // :filename is not used by trilium, but instead used for "save as" to assign a human readable filename
router.get('/share/api/images/:noteId/:filename', (req, res, next) => {
shacaLoader.ensureLoad();
@@ -118,15 +124,31 @@ function register(router) {
if (!image) {
return res.status(404).send(`Note '${req.params.noteId}' not found`);
}
- else if (image.type !== 'image') {
- return res.status(400).send("Requested note is not an image");
+ else if (!["image", "canvas"].includes(image.type)) {
+ return res.status(400).send("Requested note is not a shareable image");
+ } else if (image.type === "canvas") {
+ /**
+ * special "image" type. the canvas is actually type application/json
+ * to avoid bitrot and enable usage as referenced image the svg is included.
+ */
+ const content = image.getContent();
+ try {
+ const data = JSON.parse(content);
+
+ const svg = data.svg || '
';
+ addNoIndexHeader(image, res);
+ res.set('Content-Type', "image/svg+xml");
+ res.set("Cache-Control", "no-cache, no-store, must-revalidate");
+ res.send(svg);
+ } catch(err) {
+ res.status(500).send("there was an error parsing excalidraw to svg");
+ }
+ } else {
+ // normal image
+ res.set('Content-Type', image.mime);
+ addNoIndexHeader(image, res);
+ res.send(image.getContent());
}
-
- addNoIndexHeader(image, res);
-
- res.setHeader('Content-Type', image.mime);
-
- res.send(image.getContent());
});
// used for PDF viewing
diff --git a/trilium.iml b/trilium.iml
index 6905b014e..cffe441d0 100644
--- a/trilium.iml
+++ b/trilium.iml
@@ -13,9 +13,10 @@
+
-
+
\ No newline at end of file