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