diff --git a/apps/client/src/services/bundle.ts b/apps/client/src/services/bundle.ts index d33ba76a0a..7cee01812b 100644 --- a/apps/client/src/services/bundle.ts +++ b/apps/client/src/services/bundle.ts @@ -2,7 +2,6 @@ import { h, VNode } from "preact"; import BasicWidget, { ReactWrappedWidget } from "../widgets/basic_widget.js"; import RightPanelWidget from "../widgets/right_panel_widget.js"; -import froca from "./froca.js"; import type { Entity } from "./frontend_script_api.js"; import { WidgetDefinitionWithType } from "./frontend_script_api_preact.js"; import { t } from "./i18n.js"; @@ -38,15 +37,18 @@ async function getAndExecuteBundle(noteId: string, originEntity = null, script = export type ParentName = "left-pane" | "center-pane" | "note-detail-pane" | "right-pane"; -export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery) { +export async function executeBundleWithoutErrorHandling(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery) { const apiContext = await ScriptContext(bundle.noteId, bundle.allNoteIds, originEntity, $container); + return await function () { + return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`); + }.call(apiContext); +} +export async function executeBundle(bundle: Bundle, originEntity?: Entity | null, $container?: JQuery) { try { - return await function () { - return eval(`const apiContext = this; (async function() { ${bundle.script}\r\n})()`); - }.call(apiContext); - } catch (e: any) { - showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: e.message })); + return await executeBundleWithoutErrorHandling(bundle, originEntity, $container); + } catch (e: unknown) { + showErrorForScriptNote(bundle.noteId, t("toast.bundle-error.message", { message: getErrorMessage(e) })); logError("Widget initialization failed: ", e); } } diff --git a/apps/client/src/services/content_renderer.ts b/apps/client/src/services/content_renderer.ts index aca5d3efe3..148d59acd0 100644 --- a/apps/client/src/services/content_renderer.ts +++ b/apps/client/src/services/content_renderer.ts @@ -15,7 +15,7 @@ import protectedSessionService from "./protected_session.js"; import protectedSessionHolder from "./protected_session_holder.js"; import renderService from "./render.js"; import { applySingleBlockSyntaxHighlight } from "./syntax_highlight.js"; -import utils from "./utils.js"; +import utils, { getErrorMessage } from "./utils.js"; let idCounter = 1; @@ -62,7 +62,10 @@ export async function getRenderedContent(this: {} | { ctx: string }, entity: FNo } else if (type === "render" && entity instanceof FNote) { const $content = $("
"); - await renderService.render(entity, $content); + await renderService.render(entity, $content, (e) => { + const $error = $("
").addClass("admonition caution").text(typeof e === "string" ? e : getErrorMessage(e)); + $content.empty().append($error); + }); $renderedContent.append($content); } else if (type === "doc" && "noteId" in entity) { diff --git a/apps/client/src/services/render.ts b/apps/client/src/services/render.ts deleted file mode 100644 index f09d26532d..0000000000 --- a/apps/client/src/services/render.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { h, VNode } from "preact"; - -import type FNote from "../entities/fnote.js"; -import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx"; -import bundleService, { type Bundle } from "./bundle.js"; -import froca from "./froca.js"; -import server from "./server.js"; - -async function render(note: FNote, $el: JQuery, onError?: (e: unknown) => void) { - const relations = note.getRelations("renderNote"); - const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId); - - $el.empty().toggle(renderNoteIds.length > 0); - - for (const renderNoteId of renderNoteIds) { - const bundle = await server.post(`script/bundle/${renderNoteId}`); - - const $scriptContainer = $("
"); - $el.append($scriptContainer); - - $scriptContainer.append(bundle.html); - - // async so that scripts cannot block trilium execution - bundleService.executeBundle(bundle, note, $scriptContainer) - .catch(onError) - .then(result => { - // Render JSX - if (bundle.html === "") { - renderIfJsx(bundle, result, $el).catch(onError); - } - }); - } - - return renderNoteIds.length > 0; -} - -async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery) { - // Ensure the root script note is actually a JSX. - const rootScriptNoteId = await froca.getNote(bundle.noteId); - if (rootScriptNoteId?.mime !== "text/jsx") return; - - // Ensure the output is a valid el. - if (typeof result !== "function") return; - - // Obtain the parent component. - const closestComponent = glob.getComponentByEl($el.closest(".component")[0]); - if (!closestComponent) return; - - // Render the element. - const el = h(result as () => VNode, {}); - renderReactWidgetAtElement(closestComponent, el, $el[0]); -} - -export default { - render -}; diff --git a/apps/client/src/services/render.tsx b/apps/client/src/services/render.tsx new file mode 100644 index 0000000000..682efa8871 --- /dev/null +++ b/apps/client/src/services/render.tsx @@ -0,0 +1,86 @@ +import { Component, h, VNode } from "preact"; + +import type FNote from "../entities/fnote.js"; +import { renderReactWidgetAtElement } from "../widgets/react/react_utils.jsx"; +import { type Bundle, executeBundleWithoutErrorHandling } from "./bundle.js"; +import froca from "./froca.js"; +import server from "./server.js"; + +type ErrorHandler = (e: unknown) => void; + +async function render(note: FNote, $el: JQuery, onError?: ErrorHandler) { + const relations = note.getRelations("renderNote"); + const renderNoteIds = relations.map((rel) => rel.value).filter((noteId) => noteId); + + $el.empty().toggle(renderNoteIds.length > 0); + + try { + for (const renderNoteId of renderNoteIds) { + const bundle = await server.postWithSilentInternalServerError(`script/bundle/${renderNoteId}`); + + const $scriptContainer = $("
"); + $el.append($scriptContainer); + + $scriptContainer.append(bundle.html); + + // async so that scripts cannot block trilium execution + executeBundleWithoutErrorHandling(bundle, note, $scriptContainer) + .catch(onError) + .then(result => { + // Render JSX + if (bundle.html === "") { + renderIfJsx(bundle, result, $el, onError).catch(onError); + } + }); + } + + return renderNoteIds.length > 0; + } catch (e) { + if (typeof e === "string" && e.startsWith("{") && e.endsWith("}")) { + try { + onError?.(JSON.parse(e)); + } catch (e) { + onError?.(e); + } + } else { + onError?.(e); + } + } +} + +async function renderIfJsx(bundle: Bundle, result: unknown, $el: JQuery, onError?: ErrorHandler) { + // Ensure the root script note is actually a JSX. + const rootScriptNoteId = await froca.getNote(bundle.noteId); + if (rootScriptNoteId?.mime !== "text/jsx") return; + + // Ensure the output is a valid el. + if (typeof result !== "function") return; + + // Obtain the parent component. + const closestComponent = glob.getComponentByEl($el.closest(".component")[0]); + if (!closestComponent) return; + + // Render the element. + const UserErrorBoundary = class UserErrorBoundary extends Component { + constructor(props: object) { + super(props); + this.state = { error: null }; + } + + componentDidCatch(error: unknown) { + onError?.(error); + this.setState({ error }); + } + + render() { + if ("error" in this.state && this.state?.error) return null; + return this.props.children; + } + }; + const el = h(UserErrorBoundary, {}, h(result as () => VNode, {})); + renderReactWidgetAtElement(closestComponent, el, $el[0]); +} + +export default { + render +}; diff --git a/apps/client/src/services/server.ts b/apps/client/src/services/server.ts index 381c58a3cf..fb1e598ec2 100644 --- a/apps/client/src/services/server.ts +++ b/apps/client/src/services/server.ts @@ -73,6 +73,10 @@ async function post(url: string, data?: unknown, componentId?: string) { return await call("POST", url, componentId, { data }); } +async function postWithSilentInternalServerError(url: string, data?: unknown, componentId?: string) { + return await call("POST", url, componentId, { data, silentInternalServerError: true }); +} + async function put(url: string, data?: unknown, componentId?: string) { return await call("PUT", url, componentId, { data }); } @@ -111,6 +115,7 @@ let maxKnownEntityChangeId = 0; interface CallOptions { data?: unknown; silentNotFound?: boolean; + silentInternalServerError?: boolean; // If `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc. raw?: boolean; } @@ -143,7 +148,7 @@ async function call(method: string, url: string, componentId?: string, option }); })) as any; } else { - resp = await ajax(url, method, data, headers, !!options.silentNotFound, options.raw); + resp = await ajax(url, method, data, headers, options); } const maxEntityChangeIdStr = resp.headers["trilium-max-entity-change-id"]; @@ -155,10 +160,7 @@ async function call(method: string, url: string, componentId?: string, option return resp.body as T; } -/** - * @param raw if `true`, the value will be returned as a string instead of a JavaScript object if JSON, XMLDocument if XML, etc. - */ -function ajax(url: string, method: string, data: unknown, headers: Headers, silentNotFound: boolean, raw?: boolean): Promise { +function ajax(url: string, method: string, data: unknown, headers: Headers, opts: CallOptions): Promise { return new Promise((res, rej) => { const options: JQueryAjaxSettings = { url: window.glob.baseApiUrl + url, @@ -190,7 +192,9 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile // don't report requests that are rejected by the browser, usually when the user is refreshing or going to a different page. rej("rejected by browser"); return; - } else if (silentNotFound && jqXhr.status === 404) { + } else if (opts.silentNotFound && jqXhr.status === 404) { + // report nothing + } else if (opts.silentInternalServerError && jqXhr.status === 500) { // report nothing } else { await reportError(method, url, jqXhr.status, jqXhr.responseText); @@ -200,7 +204,7 @@ function ajax(url: string, method: string, data: unknown, headers: Headers, sile } }; - if (raw) { + if (opts.raw) { options.dataType = "text"; } @@ -299,6 +303,7 @@ export default { get, getWithSilentNotFound, post, + postWithSilentInternalServerError, put, patch, remove, diff --git a/apps/client/src/types-lib.d.ts b/apps/client/src/types-lib.d.ts index aa125f389d..4f942b8cb6 100644 --- a/apps/client/src/types-lib.d.ts +++ b/apps/client/src/types-lib.d.ts @@ -63,11 +63,13 @@ declare global { declare module "preact" { namespace JSX { + interface ElectronWebViewElement extends JSX.HTMLAttributes { + src: string; + class: string; + } + interface IntrinsicElements { - webview: { - src: string; - class: string; - } + webview: ElectronWebViewElement; } } } diff --git a/apps/client/src/types.d.ts b/apps/client/src/types.d.ts index 2e2a36e6ee..f7673901c1 100644 --- a/apps/client/src/types.d.ts +++ b/apps/client/src/types.d.ts @@ -119,7 +119,7 @@ declare global { setNote(noteId: string); } - var logError: (message: string, e?: Error | string) => void; + var logError: (message: string, e?: unknown) => void; var logInfo: (message: string) => void; var glob: CustomGlobals; //@ts-ignore diff --git a/apps/client/src/widgets/launch_bar/SyncStatus.tsx b/apps/client/src/widgets/launch_bar/SyncStatus.tsx index f5919f912b..651b89c075 100644 --- a/apps/client/src/widgets/launch_bar/SyncStatus.tsx +++ b/apps/client/src/widgets/launch_bar/SyncStatus.tsx @@ -1,12 +1,14 @@ -import { useEffect, useRef, useState } from "preact/hooks"; import "./SyncStatus.css"; -import { t } from "../../services/i18n"; -import clsx from "clsx"; -import { escapeQuotes } from "../../services/utils"; -import { useStaticTooltip, useTriliumOption } from "../react/hooks"; -import sync from "../../services/sync"; -import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws"; + import { WebSocketMessage } from "@triliumnext/commons"; +import clsx from "clsx"; +import { useEffect, useRef, useState } from "preact/hooks"; + +import { t } from "../../services/i18n"; +import sync from "../../services/sync"; +import { escapeQuotes } from "../../services/utils"; +import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws"; +import { useStaticTooltip, useTriliumOption } from "../react/hooks"; type SyncState = "unknown" | "in-progress" | "connected-with-changes" | "connected-no-changes" @@ -53,29 +55,29 @@ export default function SyncStatus() { const spanRef = useRef(null); const [ syncServerHost ] = useTriliumOption("syncServerHost"); useStaticTooltip(spanRef, { - html: true - // TODO: Placement + html: true, + title: escapeQuotes(title) }); return (syncServerHost &&
{ if (syncState === "in-progress") return; sync.syncNow(); }} > {hasChanges && ( - + )}
- ) + ); } function useSyncStatus() { diff --git a/apps/client/src/widgets/layout/ActiveContentBadges.tsx b/apps/client/src/widgets/layout/ActiveContentBadges.tsx index fcccd00f29..8d0cf20c75 100644 --- a/apps/client/src/widgets/layout/ActiveContentBadges.tsx +++ b/apps/client/src/widgets/layout/ActiveContentBadges.tsx @@ -9,7 +9,7 @@ import { openInAppHelpFromUrl } from "../../services/utils"; import { BadgeWithDropdown } from "../react/Badge"; import { FormDropdownDivider, FormListItem } from "../react/FormList"; import FormToggle from "../react/FormToggle"; -import { useNoteContext, useTriliumEvent } from "../react/hooks"; +import { useNoteContext, useNoteProperty, useTriliumEvent } from "../react/hooks"; import { BookProperty, ViewProperty } from "../react/NotePropertyMenu"; const NON_DANGEROUS_ACTIVE_CONTENT = [ "appCss", "appTheme" ]; @@ -213,6 +213,8 @@ function ActiveContentToggle({ note, info }: { note: FNote, info: ActiveContentI function useActiveContentInfo(note: FNote | null | undefined) { const [ info, setInfo ] = useState(null); + const noteType = useNoteProperty(note, "type"); + const noteMime = useNoteProperty(note, "mime"); function refresh() { let type: ActiveContentInfo["type"] | null = null; @@ -224,13 +226,13 @@ function useActiveContentInfo(note: FNote | null | undefined) { return; } - if (note.type === "render") { + if (noteType === "render") { type = "renderNote"; isEnabled = note.hasRelation("renderNote"); - } else if (note.type === "webView") { + } else if (noteType === "webView") { type = "webView"; isEnabled = note.hasLabel("webViewSrc"); - } else if (note.type === "code" && note.mime === "application/javascript;env=backend") { + } else if (noteType === "code" && noteMime === "application/javascript;env=backend") { type = "backendScript"; for (const backendLabel of [ "run", "customRequestHandler", "customResourceProvider" ]) { isEnabled ||= note.hasLabel(backendLabel); @@ -239,11 +241,11 @@ function useActiveContentInfo(note: FNote | null | undefined) { canToggleEnabled = true; } } - } else if (note.type === "code" && note.mime === "application/javascript;env=frontend") { + } else if (noteType === "code" && noteMime === "application/javascript;env=frontend") { type = "frontendScript"; isEnabled = note.hasLabel("widget") || note.hasLabel("run"); canToggleEnabled = note.hasLabelOrDisabled("widget") || note.hasLabelOrDisabled("run"); - } else if (note.type === "code" && note.hasLabelOrDisabled("appTheme")) { + } else if (noteType === "code" && note.hasLabelOrDisabled("appTheme")) { isEnabled = note.hasLabel("appTheme"); canToggleEnabled = true; } @@ -270,7 +272,7 @@ function useActiveContentInfo(note: FNote | null | undefined) { } // Refresh on note change. - useEffect(refresh, [ note ]); + useEffect(refresh, [ note, noteType, noteMime ]); useTriliumEvent("entitiesReloaded", ({ loadResults }) => { if (loadResults.getAttributeRows().some(attr => attributes.isAffecting(attr, note))) { diff --git a/apps/client/src/widgets/layout/Breadcrumb.css b/apps/client/src/widgets/layout/Breadcrumb.css index 18f88ac0c8..c32bc50dac 100644 --- a/apps/client/src/widgets/layout/Breadcrumb.css +++ b/apps/client/src/widgets/layout/Breadcrumb.css @@ -19,6 +19,9 @@ --link-hover-background: var(--icon-button-hover-background); color: var(--custom-color, inherit); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; &:hover { color: var(--custom-color, inherit); diff --git a/apps/client/src/widgets/type_widgets/WebView.tsx b/apps/client/src/widgets/type_widgets/WebView.tsx index dd3ab43c9e..bbf170933d 100644 --- a/apps/client/src/widgets/type_widgets/WebView.tsx +++ b/apps/client/src/widgets/type_widgets/WebView.tsx @@ -1,7 +1,8 @@ import "./WebView.css"; -import { useCallback, useState } from "preact/hooks"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import appContext from "../../components/app_context"; import FNote from "../../entities/fnote"; import attributes from "../../services/attributes"; import { t } from "../../services/i18n"; @@ -17,7 +18,7 @@ import { TypeWidgetProps } from "./type_widget"; const isElectron = utils.isElectron(); const HELP_PAGE = "1vHRoWCEjj0L"; -export default function WebView({ note }: TypeWidgetProps) { +export default function WebView({ note, ntxId }: TypeWidgetProps) { const [ webViewSrc ] = useNoteLabel(note, "webViewSrc"); const [ disabledWebViewSrc ] = useNoteLabel(note, "disabled:webViewSrc"); @@ -29,15 +30,58 @@ export default function WebView({ note }: TypeWidgetProps) { return ; } - return ; + return isElectron + ? + : ; } -function WebViewContent({ src }: { src: string }) { - if (!isElectron) { - return