add ability to insert mermaid diagram into text notes as image

This commit is contained in:
zadam 2023-10-21 00:23:16 +02:00
parent 9d918e7a54
commit b39ba76505
9 changed files with 115 additions and 94 deletions

View File

@ -1635,24 +1635,32 @@ class BNote extends AbstractBeccaEntity {
}
/**
* @param {string} matchBy - choose by which property we detect if to update an existing attachment.
* Supported values are either 'attachmentId' (default) or 'title'
* @returns {BAttachment}
*/
saveAttachment({attachmentId, role, mime, title, content, position}) {
saveAttachment({attachmentId, role, mime, title, content, position}, matchBy = 'attachmentId') {
if (!['attachmentId', 'title'].includes(matchBy)) {
throw new Error(`Unsupported value '${matchBy}' for matchBy param, has to be either 'attachmentId' or 'title'.`);
}
let attachment;
if (attachmentId) {
if (matchBy === 'title') {
attachment = this.getAttachmentByTitle(title);
} else if (matchBy === 'attachmentId' && attachmentId) {
attachment = this.becca.getAttachmentOrThrow(attachmentId);
} else {
attachment = new BAttachment({
ownerId: this.noteId,
title,
role,
mime,
isProtected: this.isProtected,
position
});
}
attachment = attachment || new BAttachment({
ownerId: this.noteId,
title,
role,
mime,
isProtected: this.isProtected,
position
});
content = content || "";
attachment.setContent(content, {forceSave: true});

View File

@ -78,7 +78,7 @@ import HideFloatingButtonsButton from "../widgets/floating_buttons/hide_floating
import ScriptExecutorWidget from "../widgets/ribbon_widgets/script_executor.js";
import MovePaneButton from "../widgets/buttons/move_pane_button.js";
import UploadAttachmentsDialog from "../widgets/dialogs/upload_attachments.js";
import CanvasPropertiesWidget from "../widgets/ribbon_widgets/canvas_properties.js";
import CopyImageReferenceButton from "../widgets/floating_buttons/copy_image_reference_button.js";
export default class DesktopLayout {
constructor(customWidgets) {
@ -145,7 +145,6 @@ export default class DesktopLayout {
.ribbon(new NotePropertiesWidget())
.ribbon(new FilePropertiesWidget())
.ribbon(new ImagePropertiesWidget())
.ribbon(new CanvasPropertiesWidget())
.ribbon(new BasicPropertiesWidget())
.ribbon(new OwnedAttributeListWidget())
.ribbon(new InheritedAttributesWidget())
@ -162,6 +161,7 @@ export default class DesktopLayout {
.child(new EditButton())
.child(new CodeButtonsWidget())
.child(new RelationMapButtons())
.child(new CopyImageReferenceButton())
.child(new MermaidExportButton())
.child(new BacklinksWidget())
.child(new HideFloatingButtonsButton())

View File

@ -242,7 +242,7 @@ export default class RevisionsDialog extends BasicWidget {
renderMathInElement(this.$content[0], {trust: true});
}
} else if (revisionItem.type === 'code' || revisionItem.type === 'mermaid') {
} else if (revisionItem.type === 'code') {
this.$content.html($("<pre>").text(fullRevision.content));
} else if (revisionItem.type === 'image') {
this.$content.html($("<img>")
@ -279,6 +279,14 @@ export default class RevisionsDialog extends BasicWidget {
this.$content.html($("<img>")
.attr("src", `api/revisions/${revisionItem.revisionId}/image/${sanitizedTitle}?${Math.random()}`)
.css("max-width", "100%"));
} else if (revisionItem.type === 'mermaid') {
const sanitizedTitle = revisionItem.title.replace(/[^a-z0-9-.]/gi, "");
this.$content.html($("<img>")
.attr("src", `api/revisions/${revisionItem.revisionId}/image/${sanitizedTitle}?${Math.random()}`)
.css("max-width", "100%"));
this.$content.append($("<pre>").text(fullRevision.content));
} else {
this.$content.text("Preview isn't available for this note type.");
}

View File

@ -0,0 +1,40 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import utils from "../../services/utils.js";
import imageService from "../../services/image.js";
const TPL = `
<button type="button"
class="copy-image-reference-button"
title="Copy image reference to the clipboard, can be pasted into a text note.">
<span class="bx bx-copy"></span>
<div class="hidden-image-copy"></div>
</button>`;
export default class CopyImageReferenceButton extends NoteContextAwareWidget {
isEnabled() {
return super.isEnabled()
&& ['mermaid', 'canvas'].includes(this.note?.type)
&& this.note.isContentAvailable()
&& this.noteContext?.viewScope.viewMode === 'default';
}
doRender() {
super.doRender();
this.$widget = $(TPL);
this.$hiddenImageCopy = this.$widget.find(".hidden-image-copy");
this.$widget.on('click', () => {
this.$hiddenImageCopy.empty().append(
$("<img>")
.attr("src", utils.createImageSrcUrl(this.note))
);
imageService.copyImageReferenceToClipboard(this.$hiddenImageCopy);
this.$hiddenImageCopy.empty();
});
this.contentSized();
}
}

View File

@ -1,5 +1,6 @@
import libraryLoader from "../services/library_loader.js";
import NoteContextAwareWidget from "./note_context_aware_widget.js";
import server from "../services/server.js";
const TPL = `<div class="mermaid-widget">
<style>
@ -77,6 +78,20 @@ export default class MermaidWidget extends NoteContextAwareWidget {
try {
const svg = await this.renderSvg();
if (this.dirtyAttachment) {
const payload = {
role: 'image',
title: 'mermaid-export.svg',
mime: 'image/svg+xml',
content: svg,
position: 0
};
server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload).then(() => {
this.dirtyAttachment = false;
});
}
this.$display.html(svg);
await wheelZoomLoaded;
@ -107,6 +122,8 @@ export default class MermaidWidget extends NoteContextAwareWidget {
async entitiesReloadedEvent({loadResults}) {
if (loadResults.isNoteContentReloaded(this.noteId)) {
this.dirtyAttachment = true;
await this.refresh();
}
}

View File

@ -1,56 +0,0 @@
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import utils from "../../services/utils.js";
import imageService from "../../services/image.js";
const TPL = `
<div class="image-properties">
<div style="display: flex; justify-content: space-evenly; margin: 10px;">
<button class="canvas-copy-reference-to-clipboard btn btn-sm btn-primary"
title="Pasting this reference into a text note will insert the canvas note as image"
type="button">Copy reference to clipboard</button>
</div>
<div class="hidden-canvas-copy"></div>
</div>`;
export default class CanvasPropertiesWidget extends NoteContextAwareWidget {
get name() {
return "canvasProperties";
}
get toggleCommand() {
return "toggleRibbonTabCanvasProperties";
}
isEnabled() {
return this.note && this.note.type === 'canvas';
}
getTitle() {
return {
show: this.isEnabled(),
activate: false,
title: 'Canvas',
icon: 'bx bx-pen'
};
}
doRender() {
this.$widget = $(TPL);
this.contentSized();
this.$hiddenCanvasCopy = this.$widget.find('.hidden-canvas-copy');
this.$copyReferenceToClipboardButton = this.$widget.find(".canvas-copy-reference-to-clipboard");
this.$copyReferenceToClipboardButton.on('click', () => {
this.$hiddenCanvasCopy.empty().append(
$("<img>")
.attr("src", utils.createImageSrcUrl(this.note))
);
imageService.copyImageReferenceToClipboard(this.$hiddenCanvasCopy);
this.$hiddenCanvasCopy.empty();
});
}
}

View File

@ -32,9 +32,10 @@ function getAllAttachments(req) {
function saveAttachment(req) {
const {noteId} = req.params;
const {attachmentId, role, mime, title, content} = req.body;
const {matchBy} = req.query;
const note = becca.getNoteOrThrow(noteId);
note.saveAttachment({attachmentId, role, mime, title, content});
note.saveAttachment({attachmentId, role, mime, title, content}, matchBy);
}
function uploadAttachment(req) {

View File

@ -25,33 +25,14 @@ function returnImageInt(image, res) {
if (!image) {
res.set('Content-Type', 'image/png');
return res.send(fs.readFileSync(`${RESOURCE_DIR}/db/image-deleted.png`));
} else if (!["image", "canvas"].includes(image.type)) {
} else if (!["image", "canvas", "mermaid"].includes(image.type)) {
return res.sendStatus(400);
}
/**
* 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') {
let svgString = '<svg/>'
const attachment = image.getAttachmentByTitle('canvas-export.svg');
if (attachment) {
svgString = attachment.getContent();
} else {
// backwards compatibility, before attachments, the SVG was stored in the main note content as a separate key
const contentSvg = image.getJsonContentSafely()?.svg;
if (contentSvg) {
svgString = contentSvg;
}
}
const svg = svgString
res.set('Content-Type', "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
renderSvgAttachment(image, res, 'canvas-export.svg');
} else if (image.type === 'mermaid') {
renderSvgAttachment(image, res, 'mermaid-export.svg');
} else {
res.set('Content-Type', image.mime);
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
@ -59,6 +40,28 @@ function returnImageInt(image, res) {
}
}
function renderSvgAttachment(image, res, attachmentName) {
let svgString = '<svg/>'
const attachment = image.getAttachmentByTitle(attachmentName);
if (attachment) {
svgString = attachment.getContent();
} else {
// backwards compatibility, before attachments, the SVG was stored in the main note content as a separate key
const contentSvg = image.getJsonContentSafely()?.svg;
if (contentSvg) {
svgString = contentSvg;
}
}
const svg = svgString
res.set('Content-Type', "image/svg+xml");
res.set("Cache-Control", "no-cache, no-store, must-revalidate");
res.send(svg);
}
function returnAttachedImage(req, res) {
const attachment = becca.getAttachment(req.params.attachmentId);