UX improvements for web view & render note (#8725)

This commit is contained in:
Elian Doran
2026-02-15 17:23:28 +02:00
committed by GitHub
13 changed files with 324 additions and 111 deletions

View File

@@ -168,6 +168,49 @@ function isAffecting(attrRow: AttributeRow, affectedNote: FNote | null | undefin
return false;
}
/**
* Toggles whether a dangerous attribute is enabled or not. When an attribute is disabled, its name is prefixed with `disabled:`.
*
* Note that this work for non-dangerous attributes as well.
*
* If there are multiple attributes with the same name, all of them will be toggled at the same time.
*
* @param note the note whose attribute to change.
* @param type the type of dangerous attribute (label or relation).
* @param name the name of the dangerous attribute.
* @param willEnable whether to enable or disable the attribute.
* @returns a promise that will resolve when the request to the server completes.
*/
async function toggleDangerousAttribute(note: FNote, type: "label" | "relation", name: string, willEnable: boolean) {
const attrs = [
...note.getOwnedAttributes(type, name),
...note.getOwnedAttributes(type, `disabled:${name}`)
];
for (const attr of attrs) {
const baseName = getNameWithoutDangerousPrefix(attr.name);
const newName = willEnable ? baseName : `disabled:${baseName}`;
if (newName === attr.name) continue;
// We are adding and removing afterwards to avoid a flicker (because for a moment there would be no active content attribute anymore) because the operations are done in sequence and not atomically.
if (attr.type === "label") {
await setLabel(note.noteId, newName, attr.value);
} else {
await setRelation(note.noteId, newName, attr.value);
}
await removeAttributeById(note.noteId, attr.attributeId);
}
}
/**
* Returns the name of an attribute without the `disabled:` prefix, or the same name if it's not disabled.
* @param name the name of an attribute.
* @returns the name without the `disabled:` prefix.
*/
function getNameWithoutDangerousPrefix(name: string) {
return name.startsWith("disabled:") ? name.substring(9) : name;
}
export default {
addLabel,
setLabel,
@@ -177,5 +220,7 @@ export default {
removeAttributeById,
removeOwnedLabelByName,
removeOwnedRelationByName,
isAffecting
isAffecting,
toggleDangerousAttribute,
getNameWithoutDangerousPrefix
};

View File

@@ -6,7 +6,7 @@ import bundleService, { type Bundle } from "./bundle.js";
import froca from "./froca.js";
import server from "./server.js";
async function render(note: FNote, $el: JQuery<HTMLElement>) {
async function render(note: FNote, $el: JQuery<HTMLElement>, onError?: (e: unknown) => void) {
const relations = note.getRelations("renderNote");
const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId);
@@ -21,12 +21,14 @@ async function render(note: FNote, $el: JQuery<HTMLElement>) {
$scriptContainer.append(bundle.html);
// async so that scripts cannot block trilium execution
bundleService.executeBundle(bundle, note, $scriptContainer).then(result => {
// Render JSX
if (bundle.html === "") {
renderIfJsx(bundle, result, $el);
}
});
bundleService.executeBundle(bundle, note, $scriptContainer)
.catch(onError)
.then(result => {
// Render JSX
if (bundle.html === "") {
renderIfJsx(bundle, result, $el).catch(onError);
}
});
}
return renderNoteIds.length > 0;

View File

@@ -838,7 +838,7 @@ input[type="range"] {
text-align: center;
}
.tn-centered-form input,
.tn-centered-form .input-group,
.tn-centered-form button {
margin-top: 12px;
}
}

View File

@@ -1067,15 +1067,21 @@
"click_on_canvas_to_place_new_note": "Click on canvas to place new note"
},
"render": {
"note_detail_render_help_1": "This help note is shown because this note of type Render HTML doesn't have required relation to function properly.",
"note_detail_render_help_2": "Render HTML note type is used for <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/scripts.html\">scripting</a>. In short, you have a HTML code note (optionally with some JavaScript) and this note will render it. To make it work, you need to define a <a class=\"external\" href=\"https://triliumnext.github.io/Docs/Wiki/attributes.html\">relation</a> called \"renderNote\" pointing to the HTML note to render."
"setup_title": "Display custom HTML or Preact JSX inside this note",
"setup_create_sample_preact": "Create sample note with Preact",
"setup_create_sample_html": "Create sample note with HTML",
"setup_sample_created": "A sample note was created as a child note.",
"disabled_description": "This render notes comes from an external source. To protect you from malicious content, it is not enabled by default. Make sure you trust the source before enabling it.",
"disabled_button_enable": "Enable render note"
},
"web_view_setup": {
"title": "Create a live view of a webpage directly into Trilium",
"url_placeholder": "Enter or paste the website address, for example https://triliumnotes.org",
"create_button": "Create Web View",
"invalid_url_title": "Invalid address",
"invalid_url_message": "Insert a valid web address, for example https://triliumnotes.org."
"invalid_url_message": "Insert a valid web address, for example https://triliumnotes.org.",
"disabled_description": "This web view was imported from an external source. To help protect you from phishing or malicious content, it isnt loading automatically. You can enable it if you trust the source.",
"disabled_button_enable": "Enable web view"
},
"backend_log": {
"refresh": "Refresh"
@@ -2312,5 +2318,8 @@
"menu_change_to_widget": "Change to widget",
"menu_change_to_frontend_script": "Change to frontend script",
"menu_theme_base": "Theme base"
},
"setup_form": {
"more_info": "Learn more"
}
}

View File

@@ -203,34 +203,14 @@ function ActiveContentToggle({ note, info }: { note: FNote, info: ActiveContentI
switchOffTooltip={t("active_content_badges.toggle_tooltip_disable_tooltip", { type: title })}
switchOnTooltip={t("active_content_badges.toggle_tooltip_enable_tooltip", { type: title })}
onChange={async (willEnable) => {
const attrs = note.getOwnedAttributes()
.filter(attr => {
if (attr.isInheritable) return false;
const baseName = getNameWithoutPrefix(attr.name);
return DANGEROUS_ATTRIBUTES.some(item => item.name === baseName && item.type === attr.type);
});
for (const attr of attrs) {
const baseName = getNameWithoutPrefix(attr.name);
const newName = willEnable ? baseName : `disabled:${baseName}`;
if (newName === attr.name) continue;
// We are adding and removing afterwards to avoid a flicker (because for a moment there would be no active content attribute anymore) because the operations are done in sequence and not atomically.
if (attr.type === "label") {
await attributes.setLabel(note.noteId, newName, attr.value);
} else {
await attributes.setRelation(note.noteId, newName, attr.value);
}
await attributes.removeAttributeById(note.noteId, attr.attributeId);
}
await Promise.all(note.getOwnedAttributes()
.map(attr => ({ name: attributes.getNameWithoutDangerousPrefix(attr.name), type: attr.type }))
.filter(({ name, type }) => DANGEROUS_ATTRIBUTES.some(item => item.name === name && item.type === type))
.map(({ name, type }) => attributes.toggleDangerousAttribute(note, type, name, willEnable)));
}}
/>;
}
function getNameWithoutPrefix(name: string) {
return name.startsWith("disabled:") ? name.substring(9) : name;
}
function useActiveContentInfo(note: FNote | null | undefined) {
const [ info, setInfo ] = useState<ActiveContentInfo | null>(null);
@@ -247,11 +227,9 @@ function useActiveContentInfo(note: FNote | null | undefined) {
if (note.type === "render") {
type = "renderNote";
isEnabled = note.hasRelation("renderNote");
canToggleEnabled = note.hasRelation("renderNote") || note.hasRelation("disabled:renderNote");
} else if (note.type === "webView") {
type = "webView";
isEnabled = note.hasLabel("webViewSrc");
canToggleEnabled = note.hasLabelOrDisabled("webViewSrc");
} else if (note.type === "code" && note.mime === "application/javascript;env=backend") {
type = "backendScript";
for (const backendLabel of [ "run", "customRequestHandler", "customResourceProvider" ]) {

View File

@@ -551,7 +551,12 @@ export function useNoteRelation(note: FNote | undefined | null, relationName: Re
useTriliumEvent("entitiesReloaded", ({ loadResults }) => {
for (const attr of loadResults.getAttributeRows()) {
if (attr.type === "relation" && attr.name === relationName && attributes.isAffecting(attr, note)) {
setRelationValue(attr.value ?? null);
if (!attr.isDeleted) {
setRelationValue(attr.value ?? null);
} else {
setRelationValue(null);
}
break;
}
}
});
@@ -601,6 +606,7 @@ export function useNoteLabel(note: FNote | undefined | null, labelName: FilterLa
} else {
setLabelValue(null);
}
break;
}
}
});

View File

@@ -1,8 +1,7 @@
.note-detail-render {
position: relative;
}
.note-detail-render .note-detail-render-help {
margin: 50px;
padding: 20px;
}
&>.admonition {
margin: 1em;
}
}

View File

@@ -2,22 +2,59 @@ import "./Render.css";
import { useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { t } from "../../services/i18n";
import note_create from "../../services/note_create";
import render from "../../services/render";
import Alert from "../react/Alert";
import { useTriliumEvent } from "../react/hooks";
import RawHtml from "../react/RawHtml";
import toast from "../../services/toast";
import { getErrorMessage } from "../../services/utils";
import Admonition from "../react/Admonition";
import Button, { SplitButton } from "../react/Button";
import FormGroup from "../react/FormGroup";
import { FormListItem } from "../react/FormList";
import { useNoteRelation, useTriliumEvent } from "../react/hooks";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { refToJQuerySelector } from "../react/react_utils";
import SetupForm from "./helpers/SetupForm";
import { TypeWidgetProps } from "./type_widget";
export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
const HELP_PAGE = "HcABDtFCkbFN";
const PREACT_SAMPLE = /*js*/`\
export default function() {
return <p>Hello world.</p>;
}
`;
const HTML_SAMPLE = /*html*/`\
<p>Hello world.</p>
`;
export default function Render(props: TypeWidgetProps) {
const { note } = props;
const [ renderNote ] = useNoteRelation(note, "renderNote");
const [ disabledRenderNote ] = useNoteRelation(note, "disabled:renderNote");
if (disabledRenderNote) {
return <DisabledRender {...props} />;
}
if (!renderNote) {
return <SetupRenderContent {...props} />;
}
return <RenderContent {...props} />;
}
function RenderContent({ note, noteContext, ntxId }: TypeWidgetProps) {
const contentRef = useRef<HTMLDivElement>(null);
const [ renderNotesFound, setRenderNotesFound ] = useState(false);
const [ error, setError ] = useState<unknown | null>(null);
function refresh() {
if (!contentRef) return;
render.render(note, refToJQuerySelector(contentRef)).then(setRenderNotesFound);
setError(null);
render.render(note, refToJQuerySelector(contentRef), setError);
}
useEffect(refresh, [ note ]);
@@ -49,14 +86,72 @@ export default function Render({ note, noteContext, ntxId }: TypeWidgetProps) {
return (
<>
{!renderNotesFound && (
<Alert className="note-detail-render-help" type="warning">
<p><strong>{t("render.note_detail_render_help_1")}</strong></p>
<p><RawHtml html={t("render.note_detail_render_help_2")} /></p>
</Alert>
{error && (
<Admonition type="caution">
{getErrorMessage(error)}
</Admonition>
)}
<div ref={contentRef} className="note-detail-render-content" />
</>
);
}
function DisabledRender({ note }: TypeWidgetProps) {
return (
<SetupForm
icon="bx bx-extension"
inAppHelpPage={HELP_PAGE}
>
<p>{t("render.disabled_description")}</p>
<Button
text={t("render.disabled_button_enable")}
icon="bx bx-check-shield"
onClick={() => attributes.toggleDangerousAttribute(note, "relation", "renderNote", true)}
primary
/>
</SetupForm>
);
}
function SetupRenderContent({ note }: TypeWidgetProps) {
return (
<SetupForm
icon="bx bx-extension"
inAppHelpPage={HELP_PAGE}
>
<FormGroup name="render-target-note" label={t("render.setup_title")}>
<NoteAutocomplete noteIdChanged={noteId => {
if (!noteId) return;
setRenderNote(note, noteId);
}} />
</FormGroup>
<SplitButton
text={t("render.setup_create_sample_preact")}
icon="bx bxl-react"
onClick={() => setupSampleNote(note, "text/jsx", PREACT_SAMPLE)}
>
<FormListItem
icon="bx bxl-html5"
onClick={() => setupSampleNote(note, "text/html", HTML_SAMPLE)}
>{t("render.setup_create_sample_html")}</FormListItem>
</SplitButton>
</SetupForm>
);
}
async function setRenderNote(note: FNote, targetNoteUrl: string) {
await attributes.setRelation(note.noteId, "renderNote", targetNoteUrl);
}
async function setupSampleNote(parentNote: FNote, mime: string, content: string) {
const { note: codeNote } = await note_create.createNote(parentNote.noteId, {
type: "code",
mime,
content,
activate: false
});
if (!codeNote) return;
await setRenderNote(parentNote, codeNote.noteId);
toast.showMessage(t("render.setup_sample_created"));
}

View File

@@ -16,20 +16,3 @@
width: 100%;
height: 100%;
}
.web-view-setup-form {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
padding-inline: 40px;
.form-icon {
margin-bottom: 12px;
}
.form-group {
width: 100%;
max-width: 600px;
}
}

View File

@@ -1,68 +1,103 @@
import { useCallback, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import utils from "../../services/utils";
import { useNoteLabel } from "../react/hooks";
import { TypeWidgetProps } from "./type_widget";
import "./WebView.css";
import FormGroup from "../react/FormGroup";
import { useCallback, useState } from "preact/hooks";
import FNote from "../../entities/fnote";
import attributes from "../../services/attributes";
import { t } from "../../services/i18n";
import toast from "../../services/toast";
import utils from "../../services/utils";
import Button from "../react/Button";
import FormGroup from "../react/FormGroup";
import FormTextBox from "../react/FormTextBox";
import { useNoteLabel } from "../react/hooks";
import SetupForm from "./helpers/SetupForm";
import { TypeWidgetProps } from "./type_widget";
const isElectron = utils.isElectron();
const HELP_PAGE = "1vHRoWCEjj0L";
export default function WebView({ note }: TypeWidgetProps) {
const [ webViewSrc ] = useNoteLabel(note, "webViewSrc");
const [ disabledWebViewSrc ] = useNoteLabel(note, "disabled:webViewSrc");
return (webViewSrc
? <WebViewContent src={webViewSrc} />
: <SetupWebView note={note} />
);
if (disabledWebViewSrc) {
return <DisabledWebView note={note} url={disabledWebViewSrc} />;
}
if (!webViewSrc) {
return <SetupWebView note={note} />;
}
return <WebViewContent src={webViewSrc} />;
}
function WebViewContent({ src }: { src: string }) {
if (!isElectron) {
return <iframe src={src} class="note-detail-web-view-content" sandbox="allow-same-origin allow-scripts allow-popups" />
} else {
return <webview src={src} class="note-detail-web-view-content" />
return <iframe src={src} class="note-detail-web-view-content" sandbox="allow-same-origin allow-scripts allow-popups" />;
}
return <webview src={src} class="note-detail-web-view-content" />;
}
function SetupWebView({note}: {note: FNote}) {
const [srcLabel, setSrcLabel] = useNoteLabel(note, "webViewSrc");
const [src, setSrc] = useState("");
const [ , setSrcLabel] = useNoteLabel(note, "webViewSrc");
const [ src, setSrc ] = useState("");
const submit = useCallback((url: string) => {
try {
// Validate URL
new URL(url);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (ex) {
toast.showErrorTitleAndMessage(t("web_view_setup.invalid_url_title"),
t("web_view_setup.invalid_url_message"));
t("web_view_setup.invalid_url_message"));
return;
}
setSrcLabel(url);
}, [note]);
}, [ setSrcLabel ]);
return <div class="web-view-setup-form">
<form class="tn-centered-form" onSubmit={() => submit(src)}>
<span className="bx bx-globe-alt form-icon" />
<FormGroup name="web-view-src-detail" label={t("web_view_setup.title")}>
<input className="form-control"
type="text"
value={src}
placeholder={t("web_view_setup.url_placeholder")}
onChange={(e) => {setSrc((e.target as HTMLInputElement)?.value)}}
/>
</FormGroup>
<Button
text={t("web_view_setup.create_button")}
primary
keyboardShortcut="Enter"
return (
<SetupForm
icon="bx bx-globe-alt" inAppHelpPage={HELP_PAGE}
onSubmit={() => submit(src)}
>
<FormGroup name="web-view-src-detail" label={t("web_view_setup.title")}>
<input className="form-control"
type="text"
value={src}
placeholder={t("web_view_setup.url_placeholder")}
onChange={(e) => {setSrc((e.target as HTMLInputElement)?.value);}}
/>
</form>
</div>
</FormGroup>
<Button
text={t("web_view_setup.create_button")}
primary
keyboardShortcut="Enter"
/>
</SetupForm>
);
}
function DisabledWebView({ note, url }: { note: FNote, url: string }) {
return (
<SetupForm icon="bx bx-globe-alt" inAppHelpPage={HELP_PAGE}>
<FormGroup name="web-view-src-detail" label={t("web_view_setup.disabled_description")}>
<FormTextBox
type="url"
currentValue={url}
disabled
/>
</FormGroup>
<Button
text={t("web_view_setup.disabled_button_enable")}
icon="bx bx-check-shield"
onClick={() => attributes.toggleDangerousAttribute(note, "label", "webViewSrc", true)}
primary
/>
</SetupForm>
);
}

View File

@@ -0,0 +1,22 @@
.setup-form {
height: 100%;
display: flex;
max-width: 600px;
margin: auto;
flex-direction: column;
justify-content: center;
padding-inline: 40px;
.form-icon {
margin-bottom: 12px;
}
.form-group {
width: 100%;
max-width: 600px;
}
.tn-link {
margin-top: 1em;
}
}

View File

@@ -0,0 +1,34 @@
import "./SetupForm.css";
import clsx from "clsx";
import { ComponentChildren } from "preact";
import { t } from "../../../services/i18n";
import { openInAppHelpFromUrl } from "../../../services/utils";
import LinkButton from "../../react/LinkButton";
interface SetupFormProps {
icon: string;
onSubmit?: () => void;
children: ComponentChildren;
inAppHelpPage?: string;
}
export default function SetupForm({ icon, children, onSubmit, inAppHelpPage }: SetupFormProps) {
return (
<div class="setup-form">
<form class="tn-centered-form" onSubmit={onSubmit}>
<span className={clsx(icon, "form-icon")} />
{children}
{inAppHelpPage && (
<LinkButton
text={t("setup_form.more_info")}
onClick={() => openInAppHelpFromUrl(inAppHelpPage)}
/>
)}
</form>
</div>
);
}

View File

@@ -61,6 +61,7 @@ type Labels = {
// Note-type specific
webViewSrc: string;
"disabled:webViewSrc": string;
readOnly: boolean;
mapType: string;
mapRootNoteId: string;
@@ -77,6 +78,10 @@ type Relations = [
"searchScript",
"ancestor",
// Active content
"renderNote",
"disabled:renderNote",
// Launcher-specific
"target",
"widget"