mirror of https://github.com/TriliumNext/Notes
Merge branch 'develop' into ai-llm-integration
commit
b00c20c357
@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
cd src/public
|
||||
echo Summary
|
||||
cloc HEAD \
|
||||
--git --md \
|
||||
--include-lang=javascript,typescript
|
||||
|
||||
echo By file
|
||||
cloc HEAD \
|
||||
--git --md \
|
||||
--include-lang=javascript,typescript \
|
||||
--by-file | grep \.js\|
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,24 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = `
|
||||
<button type="button"
|
||||
class="export-svg-button"
|
||||
title="${t("png_export_button.button_title")}">
|
||||
<span class="bx bxs-file-png"></span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
export default class PngExportButton extends NoteContextAwareWidget {
|
||||
isEnabled() {
|
||||
return super.isEnabled() && ["mermaid", "mindMap"].includes(this.note?.type ?? "") && this.note?.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
super.doRender();
|
||||
|
||||
this.$widget = $(TPL);
|
||||
this.$widget.on("click", () => this.triggerEvent("exportPng", { ntxId: this.ntxId }));
|
||||
this.contentSized();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import options from "../../services/options.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = `
|
||||
<button type="button"
|
||||
class="switch-layout-button">
|
||||
<span class="bx"></span>
|
||||
</button>
|
||||
`;
|
||||
|
||||
export default class SwitchSplitOrientationButton extends NoteContextAwareWidget {
|
||||
isEnabled() {
|
||||
return super.isEnabled()
|
||||
&& ["mermaid"].includes(this.note?.type ?? "")
|
||||
&& this.note?.isContentAvailable()
|
||||
&& !this.note?.hasLabel("readOnly")
|
||||
&& this.noteContext?.viewScope?.viewMode === "default";
|
||||
}
|
||||
|
||||
doRender(): void {
|
||||
super.doRender();
|
||||
this.$widget = $(TPL);
|
||||
this.$widget.on("click", () => {
|
||||
const currentOrientation = options.get("splitEditorOrientation");
|
||||
options.save("splitEditorOrientation", toggleOrientation(currentOrientation));
|
||||
});
|
||||
this.#adjustIcon();
|
||||
this.contentSized();
|
||||
}
|
||||
|
||||
#adjustIcon() {
|
||||
const currentOrientation = options.get("splitEditorOrientation");
|
||||
const upcomingOrientation = toggleOrientation(currentOrientation);
|
||||
const $icon = this.$widget.find("span.bx");
|
||||
$icon
|
||||
.toggleClass("bxs-dock-bottom", upcomingOrientation === "vertical")
|
||||
.toggleClass("bxs-dock-left", upcomingOrientation === "horizontal");
|
||||
|
||||
if (upcomingOrientation === "vertical") {
|
||||
this.$widget.attr("title", t("switch_layout_button.title_vertical"));
|
||||
} else {
|
||||
this.$widget.attr("title", t("switch_layout_button.title_horizontal"));
|
||||
}
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("splitEditorOrientation")) {
|
||||
this.#adjustIcon();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function toggleOrientation(orientation: string) {
|
||||
if (orientation === "horizontal") {
|
||||
return "vertical";
|
||||
} else {
|
||||
return "horizontal";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import attributes from "../../services/attributes.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import OnClickButtonWidget from "../buttons/onclick_button.js";
|
||||
|
||||
export default class ToggleReadOnlyButton extends OnClickButtonWidget {
|
||||
|
||||
private isReadOnly?: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this
|
||||
.title(() => this.isReadOnly ? t("toggle_read_only_button.unlock-editing") : t("toggle_read_only_button.lock-editing"))
|
||||
.titlePlacement("bottom")
|
||||
.icon(() => this.isReadOnly ? "bx-lock-open-alt" : "bx-lock-alt")
|
||||
.onClick(() => this.#toggleReadOnly());
|
||||
}
|
||||
|
||||
#toggleReadOnly() {
|
||||
if (!this.noteId || !this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isReadOnly) {
|
||||
attributes.removeOwnedLabelByName(this.note, "readOnly");
|
||||
} else {
|
||||
attributes.setLabel(this.noteId, "readOnly");
|
||||
}
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote | null | undefined) {
|
||||
const isReadOnly = !!note?.hasLabel("readOnly");
|
||||
|
||||
if (isReadOnly !== this.isReadOnly) {
|
||||
this.isReadOnly = isReadOnly;
|
||||
this.refreshIcon();
|
||||
}
|
||||
}
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled()
|
||||
&& this.note?.type === "mermaid"
|
||||
&& this.note?.isContentAvailable()
|
||||
&& this.noteContext?.viewScope?.viewMode === "default";
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,261 +0,0 @@
|
||||
import { t } from "../services/i18n.js";
|
||||
import libraryLoader from "../services/library_loader.js";
|
||||
import NoteContextAwareWidget from "./note_context_aware_widget.js";
|
||||
import server from "../services/server.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { loadElkIfNeeded, postprocessMermaidSvg } from "../services/mermaid.js";
|
||||
import type FNote from "../entities/fnote.js";
|
||||
import type { EventData } from "../components/app_context.js";
|
||||
import ScrollingContainer from "./containers/scrolling_container.js";
|
||||
import Split from "split.js";
|
||||
import { DEFAULT_GUTTER_SIZE } from "../services/resizer.js";
|
||||
|
||||
const TPL = `<div class="mermaid-widget">
|
||||
<style>
|
||||
.mermaid-widget {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
body.mobile .mermaid-widget {
|
||||
min-height: 200px;
|
||||
flex-grow: 2;
|
||||
flex-basis: 0;
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
body.desktop .mermaid-widget + .gutter {
|
||||
border-bottom: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.mermaid-render {
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="mermaid-error alert alert-warning">
|
||||
<p><strong>${t("mermaid.diagram_error")}</strong></p>
|
||||
<p class="error-content"></p>
|
||||
</div>
|
||||
|
||||
<div class="mermaid-render"></div>
|
||||
</div>`;
|
||||
|
||||
let idCounter = 1;
|
||||
|
||||
export default class MermaidWidget extends NoteContextAwareWidget {
|
||||
|
||||
private $display!: JQuery<HTMLElement>;
|
||||
private $errorContainer!: JQuery<HTMLElement>;
|
||||
private $errorMessage!: JQuery<HTMLElement>;
|
||||
private dirtyAttachment?: boolean;
|
||||
private zoomHandler?: () => void;
|
||||
private zoomInstance?: SvgPanZoom.Instance;
|
||||
private splitInstance?: Split.Instance;
|
||||
private lastNote?: FNote;
|
||||
|
||||
isEnabled() {
|
||||
return super.isEnabled() && this.note?.type === "mermaid" && this.note.isContentAvailable() && this.noteContext?.viewScope?.viewMode === "default";
|
||||
}
|
||||
|
||||
doRender() {
|
||||
this.$widget = $(TPL);
|
||||
this.contentSized();
|
||||
this.$display = this.$widget.find(".mermaid-render");
|
||||
this.$errorContainer = this.$widget.find(".mermaid-error");
|
||||
this.$errorMessage = this.$errorContainer.find(".error-content");
|
||||
}
|
||||
|
||||
async refreshWithNote(note: FNote) {
|
||||
const isSameNote = (this.lastNote === note);
|
||||
|
||||
this.cleanup();
|
||||
this.$errorContainer.hide();
|
||||
|
||||
await libraryLoader.requireLibrary(libraryLoader.MERMAID);
|
||||
|
||||
mermaid.mermaidAPI.initialize({
|
||||
startOnLoad: false,
|
||||
...(getMermaidConfig() as any)
|
||||
});
|
||||
|
||||
if (!isSameNote) {
|
||||
this.$display.empty();
|
||||
}
|
||||
|
||||
this.$errorContainer.hide();
|
||||
|
||||
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);
|
||||
this.$display.attr("id", `mermaid-render-${idCounter}`);
|
||||
|
||||
// Fit the image to bounds.
|
||||
const $svg = this.$display.find("svg");
|
||||
$svg.attr("width", "100%").attr("height", "100%");
|
||||
|
||||
// Enable pan to zoom.
|
||||
this.#setupPanZoom($svg[0], isSameNote);
|
||||
} catch (e: any) {
|
||||
console.warn(e);
|
||||
this.#cleanUpZoom();
|
||||
this.$display.empty();
|
||||
this.$errorMessage.text(e.message);
|
||||
this.$errorContainer.show();
|
||||
}
|
||||
|
||||
this.#setupResizer();
|
||||
this.lastNote = note;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
super.cleanup();
|
||||
if (this.zoomHandler) {
|
||||
$(window).off("resize", this.zoomHandler);
|
||||
this.zoomHandler = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
#cleanUpZoom() {
|
||||
if (this.zoomInstance) {
|
||||
this.zoomInstance.destroy();
|
||||
this.zoomInstance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
toggleInt(show: boolean | null | undefined): void {
|
||||
super.toggleInt(show);
|
||||
|
||||
if (!show) {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async renderSvg() {
|
||||
idCounter++;
|
||||
|
||||
if (!this.note) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const blob = await this.note.getBlob();
|
||||
const content = blob?.content || "";
|
||||
|
||||
await loadElkIfNeeded(content);
|
||||
const { svg } = await mermaid.mermaidAPI.render(`mermaid-graph-${idCounter}`, content);
|
||||
return postprocessMermaidSvg(svg);
|
||||
}
|
||||
|
||||
async #setupPanZoom(svgEl: SVGElement, isSameNote: boolean) {
|
||||
// Clean up
|
||||
let pan = null;
|
||||
let zoom = null;
|
||||
if (this.zoomInstance) {
|
||||
// Store pan and zoom for same note, when the user is editing the note.
|
||||
if (isSameNote && this.zoomInstance) {
|
||||
pan = this.zoomInstance.getPan();
|
||||
zoom = this.zoomInstance.getZoom();
|
||||
}
|
||||
this.#cleanUpZoom();
|
||||
}
|
||||
|
||||
const svgPanZoom = (await import("svg-pan-zoom")).default;
|
||||
const zoomInstance = svgPanZoom(svgEl, {
|
||||
zoomEnabled: true,
|
||||
controlIconsEnabled: true
|
||||
});
|
||||
|
||||
if (pan && zoom) {
|
||||
// Restore the pan and zoom.
|
||||
zoomInstance.zoom(zoom);
|
||||
zoomInstance.pan(pan);
|
||||
} else {
|
||||
// New instance, reposition properly.
|
||||
zoomInstance.center();
|
||||
zoomInstance.fit();
|
||||
}
|
||||
|
||||
this.zoomHandler = () => {
|
||||
zoomInstance.resize();
|
||||
zoomInstance.fit();
|
||||
zoomInstance.center();
|
||||
};
|
||||
this.zoomInstance = zoomInstance;
|
||||
$(window).on("resize", this.zoomHandler);
|
||||
}
|
||||
|
||||
#setupResizer() {
|
||||
if (!utils.isDesktop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selfEl = this.$widget;
|
||||
const scrollingContainer = this.parent?.children.find((ch) => ch instanceof ScrollingContainer)?.$widget;
|
||||
|
||||
if (!selfEl.length || !scrollingContainer?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.splitInstance) {
|
||||
this.splitInstance = Split([ selfEl[0], scrollingContainer[0] ], {
|
||||
sizes: [ 50, 50 ],
|
||||
direction: "vertical",
|
||||
gutterSize: DEFAULT_GUTTER_SIZE,
|
||||
onDragEnd: () => this.zoomHandler?.()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (this.noteId && loadResults.isNoteContentReloaded(this.noteId)) {
|
||||
this.dirtyAttachment = true;
|
||||
|
||||
await this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async exportSvgEvent({ ntxId }: EventData<"exportSvg">) {
|
||||
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid") {
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = await this.renderSvg();
|
||||
utils.downloadSvg(this.note.title, svg);
|
||||
}
|
||||
}
|
||||
|
||||
export function getMermaidConfig(): MermaidConfig {
|
||||
const documentStyle = window.getComputedStyle(document.documentElement);
|
||||
const mermaidTheme = documentStyle.getPropertyValue("--mermaid-theme");
|
||||
|
||||
return {
|
||||
theme: mermaidTheme.trim(),
|
||||
securityLevel: "antiscript",
|
||||
// TODO: Are all these options correct?
|
||||
flow: { useMaxWidth: false },
|
||||
sequence: { useMaxWidth: false },
|
||||
gantt: { useMaxWidth: false },
|
||||
class: { useMaxWidth: false },
|
||||
state: { useMaxWidth: false },
|
||||
pie: { useMaxWidth: true },
|
||||
journey: { useMaxWidth: false },
|
||||
git: { useMaxWidth: false }
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,271 @@
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import EditableCodeTypeWidget from "./editable_code.js";
|
||||
import TypeWidget from "./type_widget.js";
|
||||
import Split from "split.js";
|
||||
import { DEFAULT_GUTTER_SIZE } from "../../services/resizer.js";
|
||||
import options from "../../services/options.js";
|
||||
import type SwitchSplitOrientationButton from "../floating_buttons/switch_layout_button.js";
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type OnClickButtonWidget from "../buttons/onclick_button.js";
|
||||
|
||||
const TPL = `\
|
||||
<div class="note-detail-split note-detail-printable">
|
||||
<div class="note-detail-split-editor-col">
|
||||
<div class="note-detail-split-editor"></div>
|
||||
<div class="admonition caution note-detail-error-container hidden-ext"></div>
|
||||
</div>
|
||||
<div class="note-detail-split-preview-col">
|
||||
<div class="note-detail-split-preview"></div>
|
||||
<div class="btn-group btn-group-sm map-type-switcher content-floating-buttons preview-buttons bottom-right" role="group"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.note-detail-split {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-split-editor-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-detail-split-preview-col {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-editor {
|
||||
width: 100%;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-editor .note-detail-code {
|
||||
contain: size !important;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-error-container {
|
||||
font-family: var(--monospace-font-family);
|
||||
margin: 5px;
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-preview {
|
||||
transition: opacity 250ms ease-in-out;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-detail-split .note-detail-split-preview.on-error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Horizontal layout */
|
||||
|
||||
.note-detail-split.split-horizontal > .note-detail-split-preview-col {
|
||||
border-left: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-detail-split.split-horizontal > .note-detail-split-editor-col,
|
||||
.note-detail-split.split-horizontal > .note-detail-split-preview-col {
|
||||
height: 100%;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.note-detail-split.split-horizontal .note-detail-split-preview {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Vertical layout */
|
||||
|
||||
.note-detail-split.split-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.note-detail-split.split-vertical > .note-detail-split-editor-col,
|
||||
.note-detail-split.split-vertical > .note-detail-split-preview-col {
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
}
|
||||
|
||||
.note-detail-split.split-vertical > .note-detail-split-editor-col {
|
||||
border-top: 1px solid var(--main-border-color);
|
||||
}
|
||||
|
||||
.note-detail-split.split-vertical .note-detail-split-preview-col {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
/* Read-only view */
|
||||
|
||||
.note-detail-split.split-read-only .note-detail-split-preview-col {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
`;
|
||||
|
||||
/**
|
||||
* Abstract `TypeWidget` which contains a preview and editor pane, each displayed on half of the available screen.
|
||||
*
|
||||
* Features:
|
||||
*
|
||||
* - The two panes are resizeable via a split, on desktop. The split can be optionally customized via {@link buildSplitExtraOptions}.
|
||||
* - Can display errors to the user via {@link setError}.
|
||||
* - Horizontal or vertical orientation for the editor/preview split, adjustable via {@link SwitchSplitOrientationButton}.
|
||||
*/
|
||||
export default abstract class AbstractSplitTypeWidget extends TypeWidget {
|
||||
|
||||
private splitInstance?: Split.Instance;
|
||||
|
||||
protected $preview!: JQuery<HTMLElement>;
|
||||
private $editorCol!: JQuery<HTMLElement>;
|
||||
private $previewCol!: JQuery<HTMLElement>;
|
||||
private $editor!: JQuery<HTMLElement>;
|
||||
private $errorContainer!: JQuery<HTMLElement>;
|
||||
private editorTypeWidget: EditableCodeTypeWidget;
|
||||
private layoutOrientation?: "horizontal" | "vertical";
|
||||
private isReadOnly?: boolean;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.editorTypeWidget = new EditableCodeTypeWidget();
|
||||
this.editorTypeWidget.isEnabled = () => true;
|
||||
this.editorTypeWidget.getExtraOpts = this.buildEditorExtraOptions;
|
||||
}
|
||||
|
||||
doRender(): void {
|
||||
this.$widget = $(TPL);
|
||||
|
||||
// Preview pane
|
||||
this.$previewCol = this.$widget.find(".note-detail-split-preview-col");
|
||||
this.$preview = this.$widget.find(".note-detail-split-preview");
|
||||
|
||||
// Editor pane
|
||||
this.$editorCol = this.$widget.find(".note-detail-split-editor-col");
|
||||
this.$editor = this.$widget.find(".note-detail-split-editor");
|
||||
this.$editor.append(this.editorTypeWidget.render());
|
||||
this.$errorContainer = this.$widget.find(".note-detail-error-container");
|
||||
this.#adjustLayoutOrientation();
|
||||
|
||||
// Preview pane buttons
|
||||
const $previewButtons = this.$previewCol.find(".preview-buttons");
|
||||
const previewButtons = this.buildPreviewButtons();
|
||||
$previewButtons.toggle(previewButtons.length > 0);
|
||||
for (const previewButton of previewButtons) {
|
||||
const $button = previewButton.render();
|
||||
$button.removeClass("button-widget")
|
||||
.addClass("btn")
|
||||
.addClass("tn-tool-button");
|
||||
$previewButtons.append($button);
|
||||
previewButton.refreshIcon();
|
||||
}
|
||||
|
||||
super.doRender();
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.#destroyResizer();
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote | null | undefined) {
|
||||
this.#adjustLayoutOrientation();
|
||||
|
||||
if (note && !this.isReadOnly) {
|
||||
await this.editorTypeWidget.initialized;
|
||||
this.editorTypeWidget.noteContext = this.noteContext;
|
||||
this.editorTypeWidget.spacedUpdate = this.spacedUpdate;
|
||||
this.editorTypeWidget.doRefresh(note);
|
||||
}
|
||||
}
|
||||
|
||||
#adjustLayoutOrientation() {
|
||||
// Read-only
|
||||
const isReadOnly = this.note?.hasLabel("readOnly");
|
||||
if (this.isReadOnly !== isReadOnly) {
|
||||
this.$editorCol.toggle(!isReadOnly);
|
||||
}
|
||||
|
||||
// Vertical vs horizontal layout
|
||||
const layoutOrientation = (!utils.isMobile() ? options.get("splitEditorOrientation") ?? "horizontal" : "vertical");
|
||||
if (this.layoutOrientation !== layoutOrientation || this.isReadOnly !== isReadOnly) {
|
||||
this.$widget
|
||||
.toggleClass("split-horizontal", !isReadOnly && layoutOrientation === "horizontal")
|
||||
.toggleClass("split-vertical", !isReadOnly && layoutOrientation === "vertical")
|
||||
.toggleClass("split-read-only", isReadOnly);
|
||||
this.layoutOrientation = layoutOrientation as ("horizontal" | "vertical");
|
||||
this.isReadOnly = isReadOnly;
|
||||
this.#destroyResizer();
|
||||
}
|
||||
|
||||
if (!this.splitInstance) {
|
||||
this.#setupResizer();
|
||||
}
|
||||
}
|
||||
|
||||
#setupResizer() {
|
||||
if (!utils.isDesktop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let elements = [ this.$editorCol[0], this.$previewCol[0] ];
|
||||
if (this.layoutOrientation === "vertical") {
|
||||
elements.reverse();
|
||||
}
|
||||
|
||||
this.splitInstance?.destroy();
|
||||
|
||||
if (!this.isReadOnly) {
|
||||
this.splitInstance = Split(elements, {
|
||||
sizes: [ 50, 50 ],
|
||||
direction: this.layoutOrientation,
|
||||
gutterSize: DEFAULT_GUTTER_SIZE,
|
||||
...this.buildSplitExtraOptions()
|
||||
});
|
||||
} else {
|
||||
this.splitInstance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
#destroyResizer() {
|
||||
this.splitInstance?.destroy();
|
||||
this.splitInstance = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon when the split between the preview and content pane is initialized. Can be used to add additional listeners if needed.
|
||||
*/
|
||||
buildSplitExtraOptions(): Split.Options {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon when the code editor is being initialized. Can be used to add additional options to the editor.
|
||||
*/
|
||||
buildEditorExtraOptions(): Partial<CodeMirrorOpts> {
|
||||
return {
|
||||
lineWrapping: false
|
||||
};
|
||||
}
|
||||
|
||||
buildPreviewButtons(): OnClickButtonWidget[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
setError(message: string | null | undefined) {
|
||||
this.$errorContainer.toggleClass("hidden-ext", !message);
|
||||
this.$preview.toggleClass("on-error", !!message);
|
||||
this.$errorContainer.text(message ?? "");
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this.editorTypeWidget.getData();
|
||||
}
|
||||
|
||||
entitiesReloadedEvent({ loadResults }: EventData<"entitiesReloaded">) {
|
||||
if (loadResults.isOptionReloaded("splitEditorOrientation")) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,228 @@
|
||||
import type { EventData } from "../../components/app_context.js";
|
||||
import type FNote from "../../entities/fnote.js";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import OnClickButtonWidget from "../buttons/onclick_button.js";
|
||||
import AbstractSplitTypeWidget from "./abstract_split_type_widget.js";
|
||||
|
||||
/**
|
||||
* A specialization of `SplitTypeWidget` meant for note types that have a SVG preview.
|
||||
*
|
||||
* This adds the following functionality:
|
||||
*
|
||||
* - Automatic handling of the preview when content or the note changes via {@link renderSvg}.
|
||||
* - Built-in pan and zoom functionality with automatic re-centering.
|
||||
* - Automatically displays errors to the user if {@link renderSvg} failed.
|
||||
* - Automatically saves the SVG attachment.
|
||||
*
|
||||
*/
|
||||
export default abstract class AbstractSvgSplitTypeWidget extends AbstractSplitTypeWidget {
|
||||
|
||||
private $renderContainer!: JQuery<HTMLElement>;
|
||||
private zoomHandler: () => void;
|
||||
private zoomInstance?: SvgPanZoom.Instance;
|
||||
private svg?: string;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.zoomHandler = () => {
|
||||
if (this.zoomInstance) {
|
||||
this.zoomInstance.resize();
|
||||
this.zoomInstance.fit();
|
||||
this.zoomInstance.center();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
doRender(): void {
|
||||
super.doRender();
|
||||
this.$renderContainer = $(`<div>`)
|
||||
.addClass("render-container")
|
||||
.css("height", "100%");
|
||||
this.$preview.append(this.$renderContainer);
|
||||
$(window).on("resize", this.zoomHandler);
|
||||
}
|
||||
|
||||
async doRefresh(note: FNote | null | undefined) {
|
||||
super.doRefresh(note);
|
||||
|
||||
const blob = await note?.getBlob();
|
||||
const content = blob?.content || "";
|
||||
this.onContentChanged(content, true);
|
||||
|
||||
// Save the SVG when entering a note only when it does not have an attachment.
|
||||
this.note?.getAttachments().then((attachments) => {
|
||||
const attachmentName = `${this.attachmentName}.svg`;
|
||||
if (!attachments.find((a) => a.title === attachmentName)) {
|
||||
this.#saveSvg();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getData(): { content: string; } {
|
||||
const data = super.getData();
|
||||
this.onContentChanged(data.content, false);
|
||||
this.#saveSvg();
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggers an update of the preview pane with the provided content.
|
||||
*
|
||||
* @param content the content that will be passed to `renderSvg` for rendering. It is not the SVG content.
|
||||
* @param recenter `true` to reposition the pan/zoom to fit the image and to center it.
|
||||
*/
|
||||
async onContentChanged(content: string, recenter: boolean) {
|
||||
if (!this.note) {
|
||||
return;
|
||||
}
|
||||
|
||||
let svg: string = "";
|
||||
try {
|
||||
svg = await this.renderSvg(content);
|
||||
|
||||
// Rendering was succesful.
|
||||
this.setError(null);
|
||||
|
||||
if (svg === this.svg) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.svg = svg;
|
||||
this.$renderContainer.html(svg);
|
||||
} catch (e: unknown) {
|
||||
// Rendering failed.
|
||||
this.setError((e as Error)?.message);
|
||||
}
|
||||
|
||||
await this.#setupPanZoom(!recenter);
|
||||
}
|
||||
|
||||
#saveSvg() {
|
||||
const payload = {
|
||||
role: "image",
|
||||
title: `${this.attachmentName}.svg`,
|
||||
mime: "image/svg+xml",
|
||||
content: this.svg,
|
||||
position: 0
|
||||
};
|
||||
|
||||
server.post(`notes/${this.noteId}/attachments?matchBy=title`, payload);
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
this.#cleanUpZoom();
|
||||
$(window).off("resize", this.zoomHandler);
|
||||
super.cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called upon when the SVG preview needs refreshing, such as when the editor has switched to a new note or the content has switched.
|
||||
*
|
||||
* The method must return a valid SVG string that will be automatically displayed in the preview.
|
||||
*
|
||||
* @param content the content of the note, in plain text.
|
||||
*/
|
||||
abstract renderSvg(content: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Called to obtain the name of the note attachment (without .svg extension) that will be used for storing the preview.
|
||||
*/
|
||||
abstract get attachmentName(): string;
|
||||
|
||||
/**
|
||||
* @param preservePanZoom `true` to keep the pan/zoom settings of the previous image, or `false` to re-center it.
|
||||
*/
|
||||
async #setupPanZoom(preservePanZoom: boolean) {
|
||||
// Clean up
|
||||
let pan = null;
|
||||
let zoom = null;
|
||||
if (preservePanZoom && this.zoomInstance) {
|
||||
// Store pan and zoom for same note, when the user is editing the note.
|
||||
pan = this.zoomInstance.getPan();
|
||||
zoom = this.zoomInstance.getZoom();
|
||||
this.#cleanUpZoom();
|
||||
}
|
||||
|
||||
const $svgEl = this.$renderContainer.find("svg");
|
||||
|
||||
// Fit the image to bounds
|
||||
$svgEl.attr("width", "100%")
|
||||
.attr("height", "100%")
|
||||
.css("max-width", "100%");
|
||||
|
||||
if (!$svgEl.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const svgPanZoom = (await import("svg-pan-zoom")).default;
|
||||
const zoomInstance = svgPanZoom($svgEl[0], {
|
||||
zoomEnabled: true,
|
||||
controlIconsEnabled: false
|
||||
});
|
||||
|
||||
if (preservePanZoom && pan && zoom) {
|
||||
// Restore the pan and zoom.
|
||||
zoomInstance.zoom(zoom);
|
||||
zoomInstance.pan(pan);
|
||||
} else {
|
||||
// New instance, reposition properly.
|
||||
zoomInstance.resize();
|
||||
zoomInstance.center();
|
||||
zoomInstance.fit();
|
||||
}
|
||||
|
||||
this.zoomInstance = zoomInstance;
|
||||
}
|
||||
|
||||
buildSplitExtraOptions(): Split.Options {
|
||||
return {
|
||||
onDrag: () => this.zoomHandler?.()
|
||||
}
|
||||
}
|
||||
|
||||
buildPreviewButtons(): OnClickButtonWidget[] {
|
||||
return [
|
||||
new OnClickButtonWidget()
|
||||
.icon("bx-zoom-in")
|
||||
.title(t("relation_map_buttons.zoom_in_title"))
|
||||
.titlePlacement("top")
|
||||
.onClick(() => this.zoomInstance?.zoomIn())
|
||||
, new OnClickButtonWidget()
|
||||
.icon("bx-zoom-out")
|
||||
.title(t("relation_map_buttons.zoom_out_title"))
|
||||
.titlePlacement("top")
|
||||
.onClick(() => this.zoomInstance?.zoomOut())
|
||||
, new OnClickButtonWidget()
|
||||
.icon("bx-crop")
|
||||
.title(t("relation_map_buttons.reset_pan_zoom_title"))
|
||||
.titlePlacement("top")
|
||||
.onClick(() => this.zoomHandler())
|
||||
];
|
||||
}
|
||||
|
||||
#cleanUpZoom() {
|
||||
if (this.zoomInstance) {
|
||||
this.zoomInstance.destroy();
|
||||
this.zoomInstance = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async exportSvgEvent({ ntxId }: EventData<"exportSvg">) {
|
||||
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) {
|
||||
return;
|
||||
}
|
||||
|
||||
utils.downloadSvg(this.note.title, this.svg);
|
||||
}
|
||||
|
||||
async exportPngEvent({ ntxId }: EventData<"exportPng">) {
|
||||
if (!this.isNoteContext(ntxId) || this.note?.type !== "mermaid" || !this.svg) {
|
||||
return;
|
||||
}
|
||||
|
||||
utils.downloadSvgAsPng(this.note.title, this.svg);
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { trimIndentation } from "../../../../../../spec/support/utils.js";
|
||||
import { validateMermaid } from "./mermaid.js";
|
||||
|
||||
describe("Mermaid linter", () => {
|
||||
|
||||
(global as any).CodeMirror = {
|
||||
Pos(line: number, col: number) {
|
||||
return { line, col };
|
||||
}
|
||||
};
|
||||
|
||||
it("reports correctly bad diagram type", async () => {
|
||||
const input = trimIndentation`\
|
||||
stateDiagram-v23
|
||||
[*] -> Still
|
||||
`;
|
||||
|
||||
const result = await validateMermaid(input);
|
||||
expect(result).toMatchObject([{
|
||||
message: "Expecting 'SPACE', 'NL', 'SD', got 'ID'",
|
||||
from: { line: 0, col: 0 },
|
||||
to: { line: 0, col: 1 }
|
||||
}]);
|
||||
});
|
||||
|
||||
it("reports correctly basic arrow missing in diagram", async () => {
|
||||
const input = trimIndentation`\
|
||||
xychart-beta horizontal
|
||||
title "Percentage usge"
|
||||
x-axis [data, sys, usr, var]
|
||||
y-axis 0--->100
|
||||
bar [20, 70, 0, 0]
|
||||
`;
|
||||
|
||||
const result = await validateMermaid(input);
|
||||
expect(result).toMatchObject([{
|
||||
message: "Expecting 'ARROW_DELIMITER', got 'MINUS'",
|
||||
from: { line: 3, col: 8 },
|
||||
to: { line: 3, col: 9 }
|
||||
}]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,58 @@
|
||||
import mermaid from "mermaid";
|
||||
|
||||
interface MermaidParseError extends Error {
|
||||
hash: {
|
||||
text: string;
|
||||
token: string;
|
||||
line: number;
|
||||
loc: {
|
||||
first_line: number;
|
||||
first_column: number;
|
||||
last_line: number;
|
||||
last_column: number;
|
||||
};
|
||||
expected: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export default function registerErrorReporter() {
|
||||
CodeMirror.registerHelper("lint", null, validateMermaid);
|
||||
}
|
||||
|
||||
export async function validateMermaid(text: string) {
|
||||
if (!text.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
await mermaid.parse(text);
|
||||
} catch (e: unknown) {
|
||||
console.warn("Got validation error", JSON.stringify(e));
|
||||
|
||||
const mermaidError = (e as MermaidParseError);
|
||||
const loc = mermaidError.hash.loc;
|
||||
|
||||
let firstCol = loc.first_column + 1;
|
||||
let lastCol = loc.last_column + 1;
|
||||
|
||||
if (firstCol === 1 && lastCol === 1) {
|
||||
firstCol = 0;
|
||||
}
|
||||
|
||||
let messageLines = mermaidError.message.split("\n");
|
||||
if (messageLines.length >= 4) {
|
||||
messageLines = messageLines.slice(3);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
message: messageLines.join("\n"),
|
||||
severity: "error",
|
||||
from: CodeMirror.Pos(loc.first_line - 1, firstCol),
|
||||
to: CodeMirror.Pos(loc.last_line - 1, lastCol)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
import { getMermaidConfig, loadElkIfNeeded, postprocessMermaidSvg } from "../../services/mermaid.js";
|
||||
import AbstractSvgSplitTypeWidget from "./abstract_svg_split_type_widget.js";
|
||||
|
||||
let idCounter = 1;
|
||||
let registeredErrorReporter = false;
|
||||
|
||||
export class MermaidTypeWidget extends AbstractSvgSplitTypeWidget {
|
||||
|
||||
static getType() {
|
||||
return "mermaid";
|
||||
}
|
||||
|
||||
get attachmentName(): string {
|
||||
return "mermaid-export";
|
||||
}
|
||||
|
||||
async renderSvg(content: string) {
|
||||
const mermaid = (await import("mermaid")).default;
|
||||
await loadElkIfNeeded(mermaid, content);
|
||||
if (!registeredErrorReporter) {
|
||||
// (await import("./linters/mermaid.js")).default();
|
||||
registeredErrorReporter = true;
|
||||
}
|
||||
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
...(getMermaidConfig() as any),
|
||||
});
|
||||
|
||||
idCounter++;
|
||||
const { svg } = await mermaid.render(`mermaid-graph-${idCounter}`, content);
|
||||
return postprocessMermaidSvg(svg);
|
||||
}
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue