diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index aa33f0c7e1..465cf4b65f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -93,7 +93,7 @@ jobs: GPG_SIGNING_KEY: ${{ secrets.GPG_SIGN_KEY }} - name: Publish release - uses: softprops/action-gh-release@v2.6.1 + uses: softprops/action-gh-release@v2.6.2 if: ${{ github.event_name != 'pull_request' }} with: make_latest: false @@ -134,7 +134,7 @@ jobs: arch: ${{ matrix.arch }} - name: Publish release - uses: softprops/action-gh-release@v2.6.1 + uses: softprops/action-gh-release@v2.6.2 if: ${{ github.event_name != 'pull_request' }} with: make_latest: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 796ea9f116..57bdd00e1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,7 +150,7 @@ jobs: path: upload - name: Publish stable release - uses: softprops/action-gh-release@v2.6.1 + uses: softprops/action-gh-release@v2.6.2 with: draft: false body_path: docs/Release Notes/Release Notes/${{ github.ref_name }}.md diff --git a/.github/workflows/web-clipper.yml b/.github/workflows/web-clipper.yml index 24030e8a47..9c088dc32a 100644 --- a/.github/workflows/web-clipper.yml +++ b/.github/workflows/web-clipper.yml @@ -58,7 +58,7 @@ jobs: compression-level: 0 - name: Release web clipper extension - uses: softprops/action-gh-release@v2.6.1 + uses: softprops/action-gh-release@v2.6.2 if: ${{ startsWith(github.ref, 'refs/tags/web-clipper-v') }} with: draft: false diff --git a/apps/client/package.json b/apps/client/package.json index 157d9fabf8..0978d1b745 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -84,7 +84,7 @@ "@types/mark.js": "8.11.12", "@types/tabulator-tables": "6.3.1", "copy-webpack-plugin": "14.0.0", - "happy-dom": "20.8.9", + "happy-dom": "20.9.0", "lightningcss": "1.32.0", "script-loader": "0.7.2", "vite-plugin-static-copy": "4.0.1" diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 3ca6a2a792..680e982678 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -25,6 +25,15 @@ export type GetTextEditorCallback = (editor: CKTextEditor) => void; export type SaveState = "saved" | "saving" | "unsaved" | "error"; +const READ_ONLY_CAPABLE_TYPES: string[] = [ + "text", + "code", + "mermaid", + "canvas", + "mindMap", + "spreadsheet" +]; + export interface NoteContextDataMap { toc: HeadingContext; pdfPages: { @@ -303,8 +312,12 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return false; } - // "readOnly" is a state valid only for text/code notes - if (!this.note || (this.note.type !== "text" && this.note.type !== "code")) { + if (!this.note) { + return false; + } + + // Note types that support a read-only state (via the #readOnly label, source view, or auto-readonly). + if (!READ_ONLY_CAPABLE_TYPES.includes(this.note.type)) { return false; } @@ -320,6 +333,11 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> return true; } + // Auto read-only based on content size is only configurable for text/code. + if (this.note.type !== "text" && this.note.type !== "code") { + return false; + } + // Store the initial decision about read-only status in the viewScope // This will be "remembered" until the viewScope is refreshed if (!this.viewScope) { diff --git a/apps/client/src/entities/fnote.ts b/apps/client/src/entities/fnote.ts index a30a83975a..b371372d9c 100644 --- a/apps/client/src/entities/fnote.ts +++ b/apps/client/src/entities/fnote.ts @@ -1069,6 +1069,10 @@ export default class FNote { return this.mime === "text/x-sqlite;schema=trilium"; } + isMarkdown() { + return this.type === "code" && (this.mime === "text/markdown" || this.mime === "text/x-markdown" || this.mime === "text/x-gfm"); + } + isTriliumScript() { return this.mime.startsWith("application/javascript"); } diff --git a/apps/client/src/menus/context_menu.ts b/apps/client/src/menus/context_menu.ts index 415c0a2c6a..130b4cfd52 100644 --- a/apps/client/src/menus/context_menu.ts +++ b/apps/client/src/menus/context_menu.ts @@ -39,6 +39,7 @@ export interface MenuCommandItem { title: string; command?: T; type?: string; + mime?: string; /** * The icon to display in the menu item. * diff --git a/apps/client/src/menus/launcher_button_context_menu.ts b/apps/client/src/menus/launcher_button_context_menu.ts new file mode 100644 index 0000000000..1272deb859 --- /dev/null +++ b/apps/client/src/menus/launcher_button_context_menu.ts @@ -0,0 +1,101 @@ +import type { ToggleInParentResponse } from "@triliumnext/commons"; + +import type FNote from "../entities/fnote.js"; +import branchService from "../services/branches.js"; +import { t } from "../services/i18n.js"; +import server from "../services/server.js"; +import toast from "../services/toast.js"; +import contextMenu, { type ContextMenuEvent, type MenuItem } from "./context_menu.js"; + +const VISIBLE_LAUNCHER_PARENTS = ["_lbVisibleLaunchers", "_lbMobileVisibleLaunchers"]; + +function getVisibleLauncherBranch(launcherNote: FNote) { + return launcherNote.getParentBranches().find((b) => VISIBLE_LAUNCHER_PARENTS.includes(b.parentNoteId)); +} + +function getBookmarkBranch(launcherNote: FNote) { + return launcherNote.getParentBranches().find((b) => b.parentNoteId === "_lbBookmarks"); +} + +async function removeFromLaunchBar(launcherNote: FNote) { + const bookmarkBranch = getBookmarkBranch(launcherNote); + if (bookmarkBranch) { + // Individual bookmarks are represented via a branch under `_lbBookmarks`; removing them + // from the launch bar is the same as unbookmarking the note. + const resp = await server.put( + `notes/${launcherNote.noteId}/toggle-in-parent/_lbBookmarks/false` + ); + if (!resp.success && resp.message) { + toast.showError(resp.message); + } + return; + } + + const launcherBranch = getVisibleLauncherBranch(launcherNote); + if (!launcherBranch) return; + + const isMobileLauncher = launcherBranch.parentNoteId === "_lbMobileVisibleLaunchers"; + // Branch IDs in the hidden subtree follow the `${parentNoteId}_${noteId}` convention, + // so the branch linking `_lb(Mobile)?Root` to the "available" launchers root is predictable. + const targetBranchId = isMobileLauncher + ? "_lbMobileRoot__lbMobileAvailableLaunchers" + : "_lbRoot__lbAvailableLaunchers"; + await branchService.moveToParentNote([launcherBranch.branchId], targetBranchId); +} + +export function canRemoveFromLaunchBar(launcherNote: FNote | null | undefined) { + if (!launcherNote) return false; + return !!(getVisibleLauncherBranch(launcherNote) || getBookmarkBranch(launcherNote)); +} + +export interface ShowLauncherContextMenuOptions { + /** Menu items specific to this launcher (e.g. "Open in new tab" for note-based launchers). They appear above the "Remove from launch bar" item. */ + extraItems?: MenuItem[]; + /** Handler for the {@link extraItems}. The "Remove from launch bar" item is handled internally and will not be forwarded. */ + onCommand?: (command: T | undefined) => void; +} + +const REMOVE_COMMAND = "__removeFromLaunchBar__"; + +/** + * Displays the launch bar icon context menu. When the launcher can be removed (i.e. it is a direct + * child of the visible launchers root or of `_lbBookmarks`), a "Remove from launch bar" entry is + * appended. Extra items can be supplied to preserve launcher-specific actions (e.g. "Open in new tab"). + */ +export async function showLauncherContextMenu( + launcherNote: FNote | null | undefined, + e: ContextMenuEvent, + options: ShowLauncherContextMenuOptions = {} +) { + e.preventDefault(); + + const items = [...(options.extraItems ?? [])] as MenuItem[]; + + if (canRemoveFromLaunchBar(launcherNote)) { + if (items.length > 0) { + items.push({ kind: "separator" }); + } + items.push({ + title: t("launcher_button_context_menu.remove_from_launch_bar"), + command: REMOVE_COMMAND, + uiIcon: "bx bx-x-circle" + }); + } + + if (items.length === 0) return; + + contextMenu.show({ + x: e.pageX ?? 0, + y: e.pageY ?? 0, + items, + selectMenuItemHandler: ({ command }) => { + if (command === REMOVE_COMMAND) { + if (launcherNote) { + void removeFromLaunchBar(launcherNote); + } + return; + } + options.onCommand?.(command as T | undefined); + } + }); +} diff --git a/apps/client/src/menus/tree_context_menu.ts b/apps/client/src/menus/tree_context_menu.ts index 8dca18d905..7fc1a8b008 100644 --- a/apps/client/src/menus/tree_context_menu.ts +++ b/apps/client/src/menus/tree_context_menu.ts @@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener row !== null) as MenuItem[]; } - async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem) { + async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem) { const notePath = treeService.getNotePath(this.node); if (utils.isMobile()) { @@ -305,6 +305,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener, options: RenderOptions) { + const blob = await note.getBlob(); + const source = blob?.content ?? ""; + + if (!source.trim()) { + if (note instanceof FNote && !options.noChildrenList) { + await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false); + } + return; + } + + const html = renderToHtml(source, note.title, { + sanitize: (dirty) => DOMPurify.sanitize(dirty), + wikiLink: { formatHref: (id) => `#root/${id}` } + }); + $renderedContent.append($('
').html(html)); + await postProcessRichContent(note, $renderedContent, options); +} + /** * Renders a code note, by displaying its content and applying syntax highlighting based on the selected MIME type. */ @@ -330,6 +358,8 @@ function getRenderingType(entity: FNote | FAttachment) { if (type === "file" && mime === "application/pdf") { type = "pdf"; + } else if (type === "code" && entity instanceof FNote && entity.isMarkdown()) { + type = "markdown"; } else if ((type === "file" || type === "viewConfig") && mime && CODE_MIME_TYPES.has(mime) && !isIconPack) { type = "code"; } else if (type === "file" && mime && mime.startsWith("audio/")) { diff --git a/apps/client/src/services/content_renderer_text.ts b/apps/client/src/services/content_renderer_text.ts index 9317d0b6e0..1684ce766c 100644 --- a/apps/client/src/services/content_renderer_text.ts +++ b/apps/client/src/services/content_renderer_text.ts @@ -15,37 +15,47 @@ export default async function renderText(note: FNote | FAttachment, $renderedCon if (blob && !isHtmlEmpty(blob.content)) { $renderedContent.append($('
').html(blob.content)); - - const seenNoteIds = options.seenNoteIds ?? new Set(); - seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId); - if (!options.noIncludedNotes) { - await renderIncludedNotes($renderedContent[0], seenNoteIds); - } else { - $renderedContent.find("section.include-note").remove(); - } - - if ($renderedContent.find("span.math-tex").length > 0) { - renderMathInElement($renderedContent[0], { trust: true }); - } - - const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || ""); - const referenceLinks = $renderedContent.find("a.reference-link"); - const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el)); - await froca.getNotes(noteIdsToPrefetch); - - for (const el of referenceLinks) { - const innerSpan = document.createElement("span"); - await link.loadReferenceLinkTitle($(innerSpan), el.href); - el.replaceChildren(innerSpan); - } - - await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement); - await formatCodeBlocks($renderedContent); + await postProcessRichContent(note, $renderedContent, options); } else if (note instanceof FNote && !options.noChildrenList) { await renderChildrenList($renderedContent, note, options.includeArchivedNotes ?? false); } } +/** + * Apply the post-render passes that make CKEditor-compatible HTML fully + * interactive: expand `
`, render inline math and + * Mermaid diagrams, rewrite reference-link titles, and highlight code blocks. + * Assumes the caller has already appended the HTML inside a `.ck-content` child + * of `$renderedContent`. + */ +export async function postProcessRichContent(note: FNote | FAttachment, $renderedContent: JQuery, options: RenderOptions = {}) { + const seenNoteIds = options.seenNoteIds ?? new Set(); + seenNoteIds.add("noteId" in note ? note.noteId : note.attachmentId); + if (!options.noIncludedNotes) { + await renderIncludedNotes($renderedContent[0], seenNoteIds); + } else { + $renderedContent.find("section.include-note").remove(); + } + + if ($renderedContent.find("span.math-tex").length > 0) { + renderMathInElement($renderedContent[0], { trust: true }); + } + + const getNoteIdFromLink = (el: HTMLElement) => tree.getNoteIdFromUrl($(el).attr("href") || ""); + const referenceLinks = $renderedContent.find("a.reference-link"); + const noteIdsToPrefetch = referenceLinks.map((i, el) => getNoteIdFromLink(el)); + await froca.getNotes(noteIdsToPrefetch); + + for (const el of referenceLinks) { + const innerSpan = document.createElement("span"); + await link.loadReferenceLinkTitle($(innerSpan), el.href); + el.replaceChildren(innerSpan); + } + + await rewriteMermaidDiagramsInContainer($renderedContent[0] as HTMLDivElement); + await formatCodeBlocks($renderedContent); +} + async function renderIncludedNotes(contentEl: HTMLElement, seenNoteIds: Set) { // TODO: Consider duplicating with server's share/content_renderer.ts. const includeNoteEls = contentEl.querySelectorAll("section.include-note"); @@ -101,19 +111,107 @@ export async function rewriteMermaidDiagramsInContainer(container: HTMLDivElemen } } +/** + * Per-container cache of rendered mermaid SVG keyed by diagram source text. + * Populated after each successful render; reused on subsequent renders to + * avoid flicker when the preview HTML is regenerated (e.g. live markdown + * editing). Entries for diagrams no longer present in the container are + * evicted on each run so the cache can't grow unbounded. + */ +const mermaidSvgCache = new WeakMap>(); + +/** + * Per-container, ordered snapshot of the most recently rendered SVGs. Used as + * a positional placeholder so edits to a diagram's source keep the previous + * SVG visible while the new one renders offscreen. + */ +const mermaidLastRenderedByPosition = new WeakMap(); + export async function applyInlineMermaid(container: HTMLDivElement) { - // Initialize mermaid + const nodes = Array.from(container.querySelectorAll("div.mermaid-diagram")); + if (!nodes.length) { + mermaidLastRenderedByPosition.delete(container); + return; + } + + let cache = mermaidSvgCache.get(container); + if (!cache) { + cache = new Map(); + mermaidSvgCache.set(container, cache); + } + const lastRendered = mermaidLastRenderedByPosition.get(container) ?? []; + + // Decide per node: exact cache hit → paint final SVG; source changed → + // paint the previous SVG (by position) as a placeholder and queue an + // offscreen re-render. This way the user keeps seeing the old diagram + // until mermaid has finished producing the new one. + const pending: Array<{ visible: HTMLElement; source: string }> = []; + const seenSources = new Set(); + for (const [ index, node ] of nodes.entries()) { + const source = (node.textContent ?? "").trim(); + seenSources.add(source); + + const cached = cache.get(source); + if (cached) { + node.innerHTML = cached; + node.setAttribute("data-processed", "true"); + continue; + } + + pending.push({ visible: node, source }); + const placeholder = lastRendered[index]; + if (placeholder) { + node.innerHTML = placeholder; + } + } + + // Evict cache entries whose source is no longer present. + for (const key of [ ...cache.keys() ]) { + if (!seenSources.has(key)) cache.delete(key); + } + + if (!pending.length) { + mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML)); + return; + } + const mermaid = (await import("mermaid")).default; mermaid.initialize(getMermaidConfig()); - const nodes = Array.from(container.querySelectorAll("div.mermaid-diagram")); + + // Render clones offscreen so the visible nodes keep showing the placeholder + // until the new SVG is ready. Keeps mermaid away from our placeholder SVG + // (which would otherwise confuse its text-based parser). + const offscreen = document.createElement("div"); + offscreen.style.cssText = "position:absolute;left:-9999px;top:-9999px;width:0;height:0;overflow:hidden;visibility:hidden;"; + document.body.appendChild(offscreen); + + const pairs = pending.map(({ visible, source }) => { + const clone = document.createElement("div"); + clone.className = "mermaid-diagram"; + clone.textContent = source; + offscreen.appendChild(clone); + return { visible, clone, source }; + }); + try { - await mermaid.run({ nodes }); + await mermaid.run({ nodes: pairs.map((p) => p.clone) }); + for (const { visible, clone, source } of pairs) { + if (clone.getAttribute("data-processed") !== "true") continue; + const svg = clone.innerHTML; + visible.innerHTML = svg; + visible.setAttribute("data-processed", "true"); + cache.set(source, svg); + } } catch (e) { - console.log(e); + console.error(e); + } finally { + offscreen.remove(); } + + mermaidLastRenderedByPosition.set(container, nodes.map((n) => n.innerHTML)); } -async function renderChildrenList($renderedContent: JQuery, note: FNote, includeArchivedNotes: boolean) { +export async function renderChildrenList($renderedContent: JQuery, note: FNote, includeArchivedNotes: boolean) { let childNoteIds = note.getChildNoteIds(); if (!childNoteIds.length) { diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index c99f04ae81..e12e3c5440 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -27,7 +27,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [ // The default note type (always the first item) { type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" }, - { type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true }, + { type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true, isNew: true }, // Text notes group { type: "book", mime: "", title: t("note_types.book"), icon: "bx-book" }, @@ -49,6 +49,7 @@ export const NOTE_TYPES: NoteTypeMapping[] = [ // Code notes { type: "code", mime: "text/plain", title: t("note_types.code"), icon: "bx-code" }, + { type: "code", mime: "text/x-markdown", title: t("note_types.markdown"), icon: "bxl-markdown", isNew: true }, // Reserved types (cannot be created by the user) { type: "contentWidget", mime: "", title: t("note_types.widget"), reserved: true }, @@ -100,6 +101,7 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItemA0、A1A2A3A4A5A6LegalLetterTabloidLedger。", "color_type": "颜色", - "textarea": "多行文本" + "textarea": "多行文本", + "print_scale": "导出为 PDF 时,更改渲染内容的比例。取值范围从 0.1 (10%) 到 2 (200%),默认值为 1 (100%)。", + "print_margins": "导出为 PDF 时,设置页面边距。可以使用 defaultnoneminimum 或以毫米为单位的自定义值,例如 top,right,bottom,left。" }, "attribute_editor": { "help_text_body1": "要添加标签,只需输入例如 #rock 或者如果您还想添加值,则例如 #year = 2020", @@ -683,7 +691,12 @@ "export_as_image": "导出为图像", "export_as_image_png": "PNG(栅格)", "export_as_image_svg": "SVG(矢量图)", - "view_ocr_text": "查看 OCR 文本" + "view_ocr_text": "查看 OCR 文本", + "word_wrap": "自动换行", + "word_wrap_auto": "自动", + "word_wrap_auto_description": "遵循全局设置", + "word_wrap_on": "开启", + "word_wrap_off": "关闭" }, "onclick_button": { "no_click_handler": "按钮组件'{{componentId}}'没有定义点击处理程序" @@ -1050,15 +1063,17 @@ "title": "检查一致性", "find_and_fix_button": "查找并修复一致性问题", "finding_and_fixing_message": "正在查找并修复一致性问题...", - "issues_fixed_message": "一致性问题应该已被修复。" + "issues_fixed_message": "一致性问题应该已被修复。", + "find_and_fix_label": "查找并修复一致性问题", + "find_and_fix_description": "扫描并自动修复数据库中的任何数据一致性问题。" }, "database_anonymization": { "title": "数据库匿名化", "full_anonymization": "完全匿名化", - "full_anonymization_description": "此操作将创建一个新的数据库副本并进行匿名化处理(删除所有笔记内容,仅保留结构和一些非敏感元数据),用来分享到网上做调试而不用担心泄漏您的个人资料。", + "full_anonymization_description": "创建数据库副本,移除所有笔记内容,仅保留数据库结构和非敏感元数据。在调试问题时,可安全地在线共享。", "save_fully_anonymized_database": "保存完全匿名化的数据库", "light_anonymization": "轻度匿名化", - "light_anonymization_description": "此操作将创建一个新的数据库副本,并对其进行轻度匿名化处理——仅删除所有笔记的内容,但保留标题和属性。此外,自定义 JS 前端/后端脚本笔记和自定义小部件将保留。这提供了更多上下文以调试问题。", + "light_anonymization_description": "创建一个副本,其中移除笔记内容,但保留标题、属性和自定义脚本/小部件。这有助于提供更多调试信息。", "choose_anonymization": "您可以自行决定是提供完全匿名化还是轻度匿名化的数据库。即使是完全匿名化的数据库也非常有用,但在某些情况下,轻度匿名化的数据库可以加快错误识别和修复的过程。", "save_lightly_anonymized_database": "保存轻度匿名化的数据库", "existing_anonymized_databases": "现有的匿名化数据库", @@ -1067,14 +1082,17 @@ "error_creating_anonymized_database": "无法创建匿名化数据库,请检查后端日志以获取详细信息", "successfully_created_fully_anonymized_database": "成功创建完全匿名化的数据库,路径为 {{anonymizedFilePath}}", "successfully_created_lightly_anonymized_database": "成功创建轻度匿名化的数据库,路径为 {{anonymizedFilePath}}", - "no_anonymized_database_yet": "尚无匿名化数据库。" + "no_anonymized_database_yet": "尚无匿名化数据库。", + "description": "创建数据库的匿名副本,以便在调试问题时与开发人员共享,而不会泄露个人数据。" }, "database_integrity_check": { "title": "数据库完整性检查", "check_button": "检查数据库完整性", "checking_integrity": "正在检查数据库完整性...", "integrity_check_succeeded": "完整性检查成功 - 未发现问题。", - "integrity_check_failed": "完整性检查失败: {{results}}" + "integrity_check_failed": "完整性检查失败: {{results}}", + "check_integrity_label": "检查数据库完整性", + "check_integrity_description": "验证 SQLite 数据库是否已损坏。" }, "sync": { "title": "同步", @@ -1084,19 +1102,25 @@ "filling_entity_changes": "正在填充实体变更行...", "sync_rows_filled_successfully": "同步行填充成功", "finished-successfully": "同步已完成。", - "failed": "同步失败:{{message}}" + "failed": "同步失败:{{message}}", + "force_full_sync_label": "强制全量同步", + "force_full_sync_description": "触发与同步服务器的全量同步,重新上传所有更改。", + "fill_entity_changes_description": "重建实体变更记录。如果同步过程中缺少某些变更,请使用此功能。", + "fill_entity_changes_label": "填充实体变更" }, "vacuum_database": { "title": "数据库清理", "description": "这会重建数据库,通常会减少占用空间,不会删除数据。", "button_text": "清理数据库", "vacuuming_database": "正在清理数据库...", - "database_vacuumed": "数据库已清理" + "database_vacuumed": "数据库已清理", + "vacuum_label": "数据库清理", + "vacuum_description": "重建数据库以减小文件大小。数据不会发生任何变化。" }, "fonts": { "theme_defined": "跟随主题", "fonts": "字体", - "main_font": "主字体", + "main_font": "界面字体", "font_family": "字体系列", "size": "大小", "note_tree_font": "笔记树字体", @@ -1111,7 +1135,9 @@ "serif": "衬线", "sans-serif": "无衬线", "monospace": "等宽", - "system-default": "系统默认" + "system-default": "系统默认", + "custom_fonts": "使用自定义字体", + "preview": "预览" }, "max_content_width": { "title": "内容宽度", @@ -2292,5 +2318,35 @@ "no_providers_configured": "尚未配置任何供应商。", "provider_name": "名称", "provider_type": "供应商" + }, + "revisions": { + "note_revisions": "笔记修订", + "delete_all_revisions": "删除此笔记的所有修订版本", + "delete_all_button": "删除所有修订版本", + "help_title": "关于笔记修订的帮助", + "confirm_delete_all": "是否要删除此笔记的所有修订版本?", + "no_revisions": "这篇笔记目前还没有修改……", + "restore_button": "恢复", + "diff_on": "显示差异", + "diff_off": "显示内容", + "diff_on_hint": "点击显示笔记来源差异", + "diff_off_hint": "点击显示笔记内容", + "diff_not_available": "差异数据不可用。", + "confirm_restore": "是否恢复此版本?这将用此版本覆盖笔记的当前标题和内容。", + "delete_button": "删除", + "confirm_delete": "您要删除此修订吗?", + "revisions_deleted": "笔记修订已被删除。", + "revision_restored": "笔记修订已恢复。", + "revision_deleted": "笔记修订已删除。", + "snapshot_interval": "笔记修订快照间隔:{{seconds}}秒。", + "maximum_revisions": "笔记修订快照限制:{{number}}。", + "settings": "笔记修订设置", + "download_button": "下载", + "mime": "MIME 类型: ", + "file_size": "文件大小:", + "preview_not_available": "无法预览此类型的笔记。" + }, + "database": { + "title": "数据库" } } diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index a3761361c5..bde840adb0 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -1681,6 +1681,7 @@ "note_types": { "text": "Text", "code": "Code", + "markdown": "Markdown", "saved-search": "Saved Search", "relation-map": "Relation Map", "note-map": "Note Map", @@ -1938,6 +1939,9 @@ "move-to-available-launchers": "Move to available launchers", "duplicate-launcher": "Duplicate launcher " }, + "launcher_button_context_menu": { + "remove_from_launch_bar": "Remove from launch bar" + }, "highlighting": { "title": "Code Blocks", "description": "Controls the syntax highlighting for code blocks inside text notes, code notes will not be affected.", @@ -2082,6 +2086,11 @@ "unlock-editing": "Unlock editing", "lock-editing": "Lock editing" }, + "display_mode": { + "source": "Source view", + "split": "Split view", + "preview": "Preview" + }, "png_export_button": { "button_title": "Export diagram as PNG" }, diff --git a/apps/client/src/translations/ja/translation.json b/apps/client/src/translations/ja/translation.json index 562c2fedf8..cdbe639287 100644 --- a/apps/client/src/translations/ja/translation.json +++ b/apps/client/src/translations/ja/translation.json @@ -2446,5 +2446,38 @@ "destination_pdf": "PDF として保存", "destination_printers": "プリンター", "destination_default": "デフォルト" + }, + "revisions": { + "note_revisions": "ノートの変更履歴", + "delete_all_revisions": "このノートのすべての変更履歴を削除", + "delete_all_button": "すべての変更履歴を削除", + "help_title": "ノートの変更履歴に関するヘルプ", + "confirm_delete_all": "このノートのすべての変更履歴を削除しますか?", + "no_revisions": "このノートにはまだ変更履歴がありません...", + "restore_button": "復元", + "diff_on": "差分を表示", + "diff_off": "内容を表示", + "diff_on_hint": "クリックしてノートのソース差分を表示", + "diff_off_hint": "クリックしてノートの内容を表示", + "diff_not_available": "差分は利用できません。", + "confirm_restore": "この変更履歴を復元しますか? 復元すると、ノートの現在のタイトルと内容がこの変更履歴で上書きされます。", + "delete_button": "削除", + "confirm_delete": "この変更履歴を削除しますか?", + "revisions_deleted": "ノートの変更履歴が削除されました。", + "revision_restored": "ノートの変更履歴が復元されました。", + "revision_deleted": "ノートの変更履歴が削除されました。", + "snapshot_interval": "ノートの変更履歴スナップショット間隔: {{seconds}} 秒。", + "maximum_revisions": "ノートの変更履歴スナップショット制限: {{number}}。", + "settings": "ノートの変更履歴設定", + "download_button": "ダウンロード", + "mime": "MIME: ", + "file_size": "ファイルサイズ:", + "preview_not_available": "このノートタイプではプレビューは利用できません。" + }, + "revisions_snapshot": { + "title": "ノートの変更履歴" + }, + "launcher_button_context_menu": { + "remove_from_launch_bar": "ランチャーバーから削除" } } diff --git a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx index 5d19389cf1..e8769f0213 100644 --- a/apps/client/src/widgets/FloatingButtonsDefinitions.tsx +++ b/apps/client/src/widgets/FloatingButtonsDefinitions.tsx @@ -20,7 +20,8 @@ import tree from "../services/tree"; import { createImageSrcUrl, openInAppHelpFromUrl } from "../services/utils"; import { ViewTypeOptions } from "./collections/interface"; import ActionButton, { ActionButtonProps } from "./react/ActionButton"; -import { useIsNoteReadOnly, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks"; +import { ButtonGroup } from "./react/Button"; +import { useIsNoteReadOnly, useNoteLabel, useNoteLabelBoolean, useTriliumEvent, useTriliumOption, useWindowSize } from "./react/hooks"; import NoteLink from "./react/NoteLink"; import RawHtml from "./react/RawHtml"; @@ -47,8 +48,9 @@ export type FloatingButtonsList = ((context: FloatingButtonContext) => false | V export const DESKTOP_FLOATING_BUTTONS: FloatingButtonsList = [ RefreshBackendLogButton, - SwitchSplitOrientationButton, ToggleReadOnlyButton, + SwitchSplitOrientationButton, + DisplayModeSwitcher, EditButton, ShowTocWidgetButton, ShowHighlightsListWidgetButton, @@ -80,9 +82,13 @@ function RefreshBackendLogButton({ note, parentComponent, noteContext, isDefault } function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: FloatingButtonContext) { - const isEnabled = note.type === "mermaid" && note.isContentAvailable() && !isReadOnly && isDefaultViewMode; + const [ displayMode ] = useNoteLabel(note, "displayMode"); const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation"); const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal"; + const effectiveMode = displayMode === "source" || displayMode === "split" || displayMode === "preview" + ? displayMode + : isReadOnly ? "preview" : "split"; + const isEnabled = note.type === "mermaid" && note.isContentAvailable() && effectiveMode === "split" && isDefaultViewMode; return isEnabled && ; } +function DisplayModeSwitcher({ note, isDefaultViewMode }: FloatingButtonContext) { + const [ displayMode, setDisplayMode ] = useNoteLabel(note, "displayMode"); + const isEnabled = (note.isMarkdown() || note.type === "mermaid") && note.isContentAvailable() && isDefaultViewMode; + if (!isEnabled) return false; + + const mode = displayMode === "source" || displayMode === "preview" ? displayMode : "split"; + const buttons: Array<{ value: "source" | "split" | "preview"; icon: string; text: string }> = [ + { value: "source", icon: "bx bx-code", text: t("display_mode.source") }, + { value: "split", icon: "bx bxs-dock-left", text: t("display_mode.split") }, + { value: "preview", icon: "bx bx-show", text: t("display_mode.preview") } + ]; + + return ( + + {buttons.map(({ value, icon, text }) => ( + setDisplayMode(value)} + /> + ))} + + ); +} + function EditButton({ note, noteContext }: FloatingButtonContext) { const [animationClass, setAnimationClass] = useState(""); const {isReadOnly, enableEditing} = useIsNoteReadOnly(note, noteContext); diff --git a/apps/client/src/widgets/NoteDetail.tsx b/apps/client/src/widgets/NoteDetail.tsx index e734bbf9c7..b5f94b75fa 100644 --- a/apps/client/src/widgets/NoteDetail.tsx +++ b/apps/client/src/widgets/NoteDetail.tsx @@ -352,7 +352,9 @@ export async function getExtendedWidgetType(note: FNote | null | undefined, note resultingType = "readOnlyText"; } else if (note.isTriliumSqlite()) { resultingType = "sqlConsole"; - } else if ((type === "code" || type === "mermaid") && (await noteContext?.isReadOnly())) { + } else if (note.isMarkdown()) { + resultingType = "markdown"; + } else if (type === "code" && (await noteContext?.isReadOnly())) { resultingType = "readOnlyCode"; } else if (type === "text") { resultingType = "editableText"; diff --git a/apps/client/src/widgets/find_in_text.ts b/apps/client/src/widgets/find_in_text.ts index e97a7a8dff..049dd9f215 100644 --- a/apps/client/src/widgets/find_in_text.ts +++ b/apps/client/src/widgets/find_in_text.ts @@ -63,7 +63,7 @@ export default class FindInText { const findResultElement = editorEl?.querySelectorAll(".ck-find-result"); const scrollingContainer = editorEl?.closest('.scrolling-container'); const containerTop = scrollingContainer?.getBoundingClientRect().top ?? 0; - const closestIndex = Array.from(findResultElement ?? []).findIndex((el) => el.getBoundingClientRect().top >= containerTop); + const closestIndex = Array.from(findResultElement ?? []).findIndex((el: Element) => el.getBoundingClientRect().top >= containerTop); currentFound = closestIndex >= 0 ? closestIndex : 0; } } diff --git a/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx index 81f2c6e878..d4e761fd9d 100644 --- a/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx +++ b/apps/client/src/widgets/launch_bar/BookmarkButtons.tsx @@ -10,11 +10,11 @@ import { useChildNotes, useNote, useNoteIcon, useNoteLabelBoolean } from "../rea import NoteLink from "../react/NoteLink"; import ResponsiveContainer from "../react/ResponsiveContainer"; import { CustomNoteLauncher, launchCustomNoteLauncher } from "./GenericButtons"; -import { LaunchBarContext, LaunchBarDropdownButton, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import { LaunchBarContext, LaunchBarDropdownButton, launcherContextMenuHandler, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; const PARENT_NOTE_ID = "_lbBookmarks"; -export default function BookmarkButtons() { +export default function BookmarkButtons({ launcherNote }: LauncherNoteProps) { const { isHorizontalLayout } = useContext(LaunchBarContext); const style = useMemo(() => ({ display: "flex", @@ -22,20 +22,27 @@ export default function BookmarkButtons() { contain: "none" }), [ isHorizontalLayout ]); const childNotes = useChildNotes(PARENT_NOTE_ID); + const bookmarks = childNotes?.map(childNote => ); + const showContextMenu = launcherContextMenuHandler(launcherNote); return ( - {childNotes?.map(childNote => )} +
e.target === e.currentTarget && showContextMenu?.(e)} + > + {bookmarks}
} mobile={ - {childNotes?.map(childNote => )} + {bookmarks} } /> @@ -90,6 +97,7 @@ function BookmarkFolder({ note }: { note: FNote }) { return ( diff --git a/apps/client/src/widgets/launch_bar/CalendarWidget.tsx b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx index 1972672232..21e4689cff 100644 --- a/apps/client/src/widgets/launch_bar/CalendarWidget.tsx +++ b/apps/client/src/widgets/launch_bar/CalendarWidget.tsx @@ -58,6 +58,7 @@ export default function CalendarWidget({ launcherNote }: LauncherNoteProps) { return ( { const dateNote = appContext.tabManager.getActiveContextNote()?.getOwnedLabelValue("dateNote"); diff --git a/apps/client/src/widgets/launch_bar/GenericButtons.tsx b/apps/client/src/widgets/launch_bar/GenericButtons.tsx index 59c158dec1..6933233a66 100644 --- a/apps/client/src/widgets/launch_bar/GenericButtons.tsx +++ b/apps/client/src/widgets/launch_bar/GenericButtons.tsx @@ -1,7 +1,8 @@ import { useCallback } from "preact/hooks"; -import appContext from "../../components/app_context"; +import appContext, { CommandNames } from "../../components/app_context"; import FNote from "../../entities/fnote"; +import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu"; import link_context_menu from "../../menus/link_context_menu"; import { isCtrlKey } from "../../services/utils"; import { useGlobalShortcut, useNoteLabel } from "../react/hooks"; @@ -13,7 +14,7 @@ export function CustomNoteLauncher(props: { getHoistedNoteId?: (launcherNote: FNote) => string | null; keyboardShortcut?: string; }) { - const { launcherNote, getTargetNoteId } = props; + const { launcherNote, getTargetNoteId, getHoistedNoteId } = props; const { icon, title } = useLauncherIconAndTitle(launcherNote); const launch = useCallback(async (evt: MouseEvent | KeyboardEvent) => { @@ -31,11 +32,20 @@ export function CustomNoteLauncher(props: { onClick={launch} onAuxClick={launch} onContextMenu={async evt => { + // Must preventDefault synchronously — awaiting getTargetNoteId first would let the + // native browser context menu open before showLauncherContextMenu gets a chance to. evt.preventDefault(); const targetNoteId = await getTargetNoteId(launcherNote); - if (targetNoteId) { - link_context_menu.openContextMenu(targetNoteId, evt); - } + const hoistedNoteId = getHoistedNoteId?.(launcherNote) ?? null; + const linkItems = targetNoteId ? link_context_menu.getItems(evt) : []; + await showLauncherContextMenu(launcherNote, evt, { + extraItems: linkItems, + onCommand: (command) => { + if (command && targetNoteId) { + link_context_menu.handleLinkContextMenuItem(command, evt, targetNoteId, {}, hoistedNoteId); + } + } + }); }} /> ); diff --git a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx index 3e0ce6f967..b35e38f337 100644 --- a/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx +++ b/apps/client/src/widgets/launch_bar/HistoryNavigation.tsx @@ -3,6 +3,7 @@ import { useMemo } from "preact/hooks"; import FNote from "../../entities/fnote"; import contextMenu, { MenuCommandItem } from "../../menus/context_menu"; +import { showLauncherContextMenu } from "../../menus/launcher_button_context_menu"; import froca from "../../services/froca"; import link from "../../services/link"; import tree from "../../services/tree"; @@ -25,46 +26,63 @@ export default function HistoryNavigationButton({ launcherNote, command }: Histo icon={icon} text={title} triggerCommand={command} - onContextMenu={webContents ? handleHistoryContextMenu(webContents) : undefined} + onContextMenu={async (e) => { + // Prevent the native menu synchronously before awaiting history items. + e.preventDefault(); + const items = webContents ? await getHistoryItems(webContents) : []; + showLauncherContextMenu(launcherNote, e, { + extraItems: items, + onCommand: (cmd) => { + if (cmd && webContents) { + webContents.navigationHistory.goToIndex(parseInt(cmd, 10)); + } + } + }); + }} /> ); } +async function getHistoryItems(webContents: WebContents): Promise[]> { + if (webContents.navigationHistory.length() < 2) return []; + + let items: MenuCommandItem[] = []; + + const history = webContents.navigationHistory.getAllEntries(); + const activeIndex = webContents.navigationHistory.getActiveIndex(); + + for (const idx in history) { + const { noteId, notePath } = link.parseNavigationStateFromUrl(history[idx].url); + if (!noteId || !notePath) continue; + + const title = await tree.getNotePathTitle(notePath); + const index = parseInt(idx, 10); + const note = froca.getNoteFromCache(noteId); + + items.push({ + title, + command: idx, + checked: index === activeIndex, + enabled: index !== activeIndex, + uiIcon: note?.getIcon() + }); + } + + items.reverse(); + + if (items.length > HISTORY_LIMIT) { + items = items.slice(0, HISTORY_LIMIT); + } + + return items; +} + export function handleHistoryContextMenu(webContents: WebContents) { return async (e: MouseEvent) => { e.preventDefault(); - if (!webContents || webContents.navigationHistory.length() < 2) { - return; - } - - let items: MenuCommandItem[] = []; - - const history = webContents.navigationHistory.getAllEntries(); - const activeIndex = webContents.navigationHistory.getActiveIndex(); - - for (const idx in history) { - const { noteId, notePath } = link.parseNavigationStateFromUrl(history[idx].url); - if (!noteId || !notePath) continue; - - const title = await tree.getNotePathTitle(notePath); - const index = parseInt(idx, 10); - const note = froca.getNoteFromCache(noteId); - - items.push({ - title, - command: idx, - checked: index === activeIndex, - enabled: index !== activeIndex, - uiIcon: note?.getIcon() - }); - } - - items.reverse(); - - if (items.length > HISTORY_LIMIT) { - items = items.slice(0, HISTORY_LIMIT); - } + const items = await getHistoryItems(webContents); + if (items.length === 0) return; contextMenu.show({ x: e.pageX, diff --git a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx index d5d443084b..dd3ae9d6e3 100644 --- a/apps/client/src/widgets/launch_bar/LauncherContainer.tsx +++ b/apps/client/src/widgets/launch_bar/LauncherContainer.tsx @@ -83,13 +83,13 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { const baseSize = parseInt(note.getLabelValue("baseSize") || "40"); const growthFactor = parseInt(note.getLabelValue("growthFactor") || "100"); - return ; + return ; case "bookmarks": - return ; + return ; case "protectedSession": - return ; + return ; case "syncStatus": - return ; + return ; case "backInHistoryButton": return ; case "forwardInHistoryButton": @@ -97,11 +97,11 @@ function initBuiltinWidget(note: FNote, isHorizontalLayout: boolean) { case "todayInJournal": return ; case "quickSearch": - return ; + return ; case "mobileTabSwitcher": - return ; + return ; case "sidebarChat": - return isExperimentalFeatureEnabled("llm") ? : undefined; + return isExperimentalFeatureEnabled("llm") ? : undefined; default: console.warn(`Unrecognized builtin widget ${builtinWidget} for launcher ${note.noteId} "${note.title}"`); } diff --git a/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx index 408780224c..89bdb39194 100644 --- a/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx +++ b/apps/client/src/widgets/launch_bar/LauncherDefinitions.tsx @@ -14,7 +14,7 @@ import QuickSearchWidget from "../quick_search"; import { useGlobalShortcut, useLegacyWidget, useNoteLabel, useNoteRelationTarget } from "../react/hooks"; import { ParentComponent } from "../react/react_utils"; import { CustomNoteLauncher } from "./GenericButtons"; -import { LaunchBarActionButton, LaunchBarContext, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; +import { LaunchBarActionButton, LaunchBarContext, launcherContextMenuHandler, LauncherNoteProps, useLauncherIconAndTitle } from "./launch_bar_widgets"; export function CommandButton({ launcherNote }: LauncherNoteProps) { const { icon, title } = useLauncherIconAndTitle(launcherNote); @@ -22,6 +22,7 @@ export function CommandButton({ launcherNote }: LauncherNoteProps) { return command && ( new QuickSearchWidget(), []); const parentComponent = useContext(ParentComponent) as BasicWidget | null; @@ -101,7 +103,7 @@ export function QuickSearchLauncherWidget() { parentComponent?.contentSized(); return ( -
+
{isEnabled && }
); @@ -136,7 +138,7 @@ export function CustomWidget({ launcherNote }: LauncherNoteProps) { }, [ widgetNote ]); return ( -
+
{widget && ( ("type" in widget && widget.type === "preact-launcher-widget") ? diff --git a/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx index 539643d4f9..8e9bcf3bf9 100644 --- a/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx +++ b/apps/client/src/widgets/launch_bar/ProtectedSessionStatusWidget.tsx @@ -1,21 +1,23 @@ import { useState } from "preact/hooks"; import protected_session_holder from "../../services/protected_session_holder"; -import { LaunchBarActionButton } from "./launch_bar_widgets"; +import { LaunchBarActionButton, LauncherNoteProps } from "./launch_bar_widgets"; import { useTriliumEvent } from "../react/hooks"; import { t } from "../../services/i18n"; -export default function ProtectedSessionStatusWidget() { +export default function ProtectedSessionStatusWidget({ launcherNote }: LauncherNoteProps) { const protectedSessionAvailable = useProtectedSessionAvailable(); return ( protectedSessionAvailable ? ( ) : ( { // Open right pane if hidden, or toggle it if visible appContext.triggerEvent("toggleRightPane", {}); @@ -16,6 +16,7 @@ export default function SidebarChatButton() { return ( { - e.preventDefault(); - contextMenu.show({ - x: e.pageX, - y: e.pageY, - items: [{ title: t("spacer.configure_launchbar"), command: "showLaunchBarSubtree", uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar") }], - selectMenuItemHandler: ({ command }) => { - if (command) { - appContext.triggerCommand(command); - } - } - }); - }} + onContextMenu={launcherNote ? (e) => showLauncherContextMenu(launcherNote, e, { + extraItems: [{ + title: t("spacer.configure_launchbar"), + command: "showLaunchBarSubtree", + uiIcon: "bx " + (isMobile() ? "bx-mobile" : "bx-sidebar") + }], + onCommand: (command) => { + if (command) appContext.triggerCommand(command); + } + }) : undefined} /> ) } diff --git a/apps/client/src/widgets/launch_bar/SyncStatus.tsx b/apps/client/src/widgets/launch_bar/SyncStatus.tsx index 651b89c075..27678df17b 100644 --- a/apps/client/src/widgets/launch_bar/SyncStatus.tsx +++ b/apps/client/src/widgets/launch_bar/SyncStatus.tsx @@ -9,6 +9,7 @@ import sync from "../../services/sync"; import { escapeQuotes } from "../../services/utils"; import ws, { subscribeToMessages, unsubscribeToMessage } from "../../services/ws"; import { useStaticTooltip, useTriliumOption } from "../react/hooks"; +import { launcherContextMenuHandler, LauncherNoteProps } from "./launch_bar_widgets"; type SyncState = "unknown" | "in-progress" | "connected-with-changes" | "connected-no-changes" @@ -49,7 +50,7 @@ const STATE_MAPPINGS: Record = { } }; -export default function SyncStatus() { +export default function SyncStatus({ launcherNote }: LauncherNoteProps) { const syncState = useSyncStatus(); const { title, icon, hasChanges } = STATE_MAPPINGS[syncState]; const spanRef = useRef(null); @@ -60,7 +61,10 @@ export default function SyncStatus() { }); return (syncServerHost && -
+
) { +/** Builds the default right-click handler that shows the launch-bar icon context menu (with the "Remove from launch bar" entry). Used by widgets that render a raw element rather than going through {@link LaunchBarActionButton} / {@link LaunchBarDropdownButton}. */ +export function launcherContextMenuHandler(launcherNote: FNote | null | undefined) { + if (!launcherNote) return undefined; + return (e: MouseEvent) => showLauncherContextMenu(launcherNote, e); +} + +export function LaunchBarActionButton({ className, launcherNote, onContextMenu, ...props }: Omit & { launcherNote?: FNote }) { const { isHorizontalLayout } = useContext(LaunchBarContext); return ( @@ -30,15 +37,20 @@ export function LaunchBarActionButton({ className, ...props }: Omit ); } -export function LaunchBarDropdownButton({ children, icon, dropdownOptions, ...props }: Pick & { icon: string }) { +export function LaunchBarDropdownButton({ children, icon, dropdownOptions, launcherNote, buttonProps, ...props }: Pick & { icon: string, launcherNote?: FNote }) { const { isHorizontalLayout } = useContext(LaunchBarContext); const titlePosition = getTitlePosition(isHorizontalLayout); + const resolvedButtonProps = launcherNote && !buttonProps?.onContextMenu + ? { ...buttonProps, onContextMenu: launcherContextMenuHandler(launcherNote) } + : buttonProps; + return ( {children} ); diff --git a/apps/client/src/widgets/layout/InlineTitle.tsx b/apps/client/src/widgets/layout/InlineTitle.tsx index f6e5a51556..d4e2602f9e 100644 --- a/apps/client/src/widgets/layout/InlineTitle.tsx +++ b/apps/client/src/widgets/layout/InlineTitle.tsx @@ -75,6 +75,7 @@ function shouldShow(note: FNote | null | undefined, type: NoteType | undefined, if (viewScope?.viewMode !== "default") return false; if (note?.noteId?.startsWith("_options")) return true; if (note?.isTriliumSqlite()) return false; + if (note?.isMarkdown()) return false; return type && supportedNoteTypes.has(type); } diff --git a/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx b/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx index cf685828aa..5babfbef7f 100644 --- a/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx +++ b/apps/client/src/widgets/layout/NoteTypeSwitcher.tsx @@ -41,7 +41,7 @@ export default function NoteTypeSwitcher() { const currentNoteTypeData = useMemo(() => NOTE_TYPES.find(t => t.type === currentNoteType), [ currentNoteType ]); const { builtinTemplates, collectionTemplates } = useBuiltinTemplates(); - return (currentNoteType && supportedNoteTypes.has(currentNoteType) && !note?.isTriliumSqlite() && + return (currentNoteType && supportedNoteTypes.has(currentNoteType) && !note?.isTriliumSqlite() && !note?.isMarkdown() &&
, string> = { ocr: "bx bx-text" }; -export default function TabSwitcher() { +export default function TabSwitcher({ launcherNote }: LauncherNoteProps) { const [ shown, setShown ] = useState(false); const mainNoteContexts = useMainNoteContexts(); return ( <> | "empty" | "readOnlyCode" | "readOnlyText" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "llmChat"; +export type ExtendedNoteType = Exclude | "empty" | "readOnlyCode" | "readOnlyText" | "readOnlyOCRText" | "editableText" | "editableCode" | "attachmentDetail" | "attachmentList" | "protectedSession" | "sqlConsole" | "markdown" | "llmChat"; export type TypeWidget = ((props: TypeWidgetProps) => VNode | JSX.Element | undefined); type NoteTypeView = () => (Promise<{ default: TypeWidget } | TypeWidget> | TypeWidget); @@ -147,6 +147,12 @@ export const TYPE_MAPPINGS: Record = { className: "sql-console-widget-container", isFullHeight: true }, + markdown: { + view: () => import("./type_widgets/code/Markdown"), + className: "note-detail-markdown", + printable: true, + isFullHeight: true + }, spreadsheet: { view: () => import("./type_widgets/spreadsheet/Spreadsheet"), className: "note-detail-spreadsheet", diff --git a/apps/client/src/widgets/react/Button.tsx b/apps/client/src/widgets/react/Button.tsx index 3114b9dfe8..95601a9228 100644 --- a/apps/client/src/widgets/react/Button.tsx +++ b/apps/client/src/widgets/react/Button.tsx @@ -84,9 +84,9 @@ function Button({ name, buttonRef, className, text, onClick, keyboardShortcut, i ); } -export function ButtonGroup({ children }: { children: ComponentChildren }) { +export function ButtonGroup({ size, className, children }: { size?: "sm" | "lg"; className?: string; children: ComponentChildren }) { return ( -
+
{children}
); diff --git a/apps/client/src/widgets/react/hooks.tsx b/apps/client/src/widgets/react/hooks.tsx index 5b23276923..d507c7e962 100644 --- a/apps/client/src/widgets/react/hooks.tsx +++ b/apps/client/src/widgets/react/hooks.tsx @@ -2,7 +2,7 @@ import { CKTextEditor } from "@triliumnext/ckeditor5"; import { FilterLabelsByType, KeyboardActionNames, NoteType, OptionNames, RelationNames } from "@triliumnext/commons"; import { Tooltip } from "bootstrap"; import Mark from "mark.js"; -import { RefObject, VNode } from "preact"; +import { Ref, RefObject, VNode } from "preact"; import { CSSProperties, useSyncExternalStore } from "preact/compat"; import { MutableRef, useCallback, useContext, useDebugValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; @@ -964,11 +964,13 @@ export function useLegacyImperativeHandlers(handlers: Record) }, [ handlers ]); } -export function useSyncedRef(externalRef?: RefObject, initialValue: T | null = null): RefObject { +export function useSyncedRef(externalRef?: Ref, initialValue: T | null = null): RefObject { const ref = useRef(initialValue); useEffect(() => { - if (externalRef) { + if (typeof externalRef === "function") { + externalRef(ref.current); + } else if (externalRef) { externalRef.current = ref.current; } }, [ ref, externalRef ]); @@ -1140,6 +1142,29 @@ export function useIsNoteReadOnly(note: FNote | null | undefined, noteContext: N return { isReadOnly, enableEditing, temporarilyEditable }; } +/** + * Synchronous effective read-only state for widgets that honor the `#readOnly` label + * (mermaid, canvas, mind map, spreadsheet). Combines the label with the temporary + * "enable editing" toggle (driven by `readOnlyTemporarilyDisabled`) so clicking the + * read-only badge unlocks the widget. + */ +export function useEffectiveReadOnly(note: FNote | null | undefined, noteContext: NoteContext | undefined) { + const [ readOnlyLabel ] = useNoteLabelBoolean(note, "readOnly"); + const [ tempDisabled, setTempDisabled ] = useState(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled); + + useEffect(() => { + setTempDisabled(!!noteContext?.viewScope?.readOnlyTemporarilyDisabled); + }, [ note, noteContext, noteContext?.viewScope ]); + + useTriliumEvent("readOnlyTemporarilyDisabled", ({ noteContext: eventNoteContext }) => { + if (noteContext?.ntxId === eventNoteContext?.ntxId) { + setTempDisabled(!!eventNoteContext?.viewScope?.readOnlyTemporarilyDisabled); + } + }); + + return readOnlyLabel && !tempDisabled; +} + async function isNoteReadOnly(note: FNote, noteContext: NoteContext) { if (note.isProtected && !protected_session_holder.isProtectedSessionAvailable()) { diff --git a/apps/client/src/widgets/ribbon/NoteActions.tsx b/apps/client/src/widgets/ribbon/NoteActions.tsx index 679b624c10..6490542d78 100644 --- a/apps/client/src/widgets/ribbon/NoteActions.tsx +++ b/apps/client/src/widgets/ribbon/NoteActions.tsx @@ -75,7 +75,8 @@ export function NoteContextMenu({ note, noteContext, itemsAtStart, itemsNearNote const noteType = useNoteProperty(note, "type") ?? ""; const [viewType] = useNoteLabel(note, "viewType"); const canBeConvertedToAttachment = note?.isEligibleForConversionToAttachment(); - const isSearchable = ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType); + const isSourceView = noteContext?.viewScope?.viewMode === "source"; + const isSearchable = isSourceView || ["text", "code", "book", "mindMap", "doc", "spreadsheet"].includes(noteType); const isInOptionsOrHelp = note?.noteId.startsWith("_options") || note?.noteId.startsWith("_help"); const isExportableToImage = ["mermaid", "mindMap"].includes(noteType); const isContentAvailable = note.isContentAvailable(); diff --git a/apps/client/src/widgets/ribbon/NoteActionsCustom.css b/apps/client/src/widgets/ribbon/NoteActionsCustom.css index 3c2a2000ce..5e232b114e 100644 --- a/apps/client/src/widgets/ribbon/NoteActionsCustom.css +++ b/apps/client/src/widgets/ribbon/NoteActionsCustom.css @@ -1,3 +1,31 @@ body.mobile .note-actions-custom:not(:empty) { margin-bottom: calc(var(--bs-dropdown-divider-margin-y) * 2); } + +body.mobile .note-actions-custom-display-mode { + display: grid; + grid-template-columns: repeat(3, 1fr); + + & > .dropdown-item { + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + gap: 0.25em; + padding-inline: 0.25em; + font-size: 0.85em; + border-radius: 0 !important; + border: 0 !important; + } + + & > .dropdown-item:first-child { + border-start-start-radius: var(--bs-border-radius) !important; + border-end-start-radius: var(--bs-border-radius) !important; + } + + & > .dropdown-item:last-child { + border-start-end-radius: var(--bs-border-radius) !important; + border-end-end-radius: var(--bs-border-radius) !important; + } +} + diff --git a/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx b/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx index 92587d2ca9..15120fd2aa 100644 --- a/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx +++ b/apps/client/src/widgets/ribbon/NoteActionsCustom.tsx @@ -14,6 +14,7 @@ import { createImageSrcUrl, isMobile, openInAppHelpFromUrl } from "../../service import { ViewTypeOptions } from "../collections/interface"; import { buildSaveSqlToNoteHandler } from "../FloatingButtonsDefinitions"; import ActionButton, { ActionButtonProps } from "../react/ActionButton"; +import { ButtonGroup } from "../react/Button"; import { FormFileUploadActionButton, FormFileUploadFormListItem, FormFileUploadProps } from "../react/FormFileUpload"; import { FormListItem } from "../react/FormList"; import { useNoteLabel, useNoteLabelBoolean, useNoteProperty, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; @@ -72,7 +73,7 @@ export default function NoteActionsCustom(props: NoteActionsCustomProps) { - + @@ -189,28 +190,66 @@ function RefreshButton({ note, noteType, isDefaultViewMode, parentComponent, not function SwitchSplitOrientationButton({ note, isReadOnly, isDefaultViewMode }: NoteActionsCustomInnerProps) { const isShown = note.type === "mermaid" && !cachedIsMobile && note.isContentAvailable() && isDefaultViewMode; + const [ displayMode ] = useNoteLabel(note, "displayMode"); const [ splitEditorOrientation, setSplitEditorOrientation ] = useTriliumOption("splitEditorOrientation"); const upcomingOrientation = splitEditorOrientation === "horizontal" ? "vertical" : "horizontal"; + const effectiveMode = displayMode === "source" || displayMode === "split" || displayMode === "preview" + ? displayMode + : isReadOnly ? "preview" : "split"; return isShown && setSplitEditorOrientation(upcomingOrientation)} - disabled={isReadOnly} + disabled={effectiveMode !== "split"} />; } -export function ToggleReadOnlyButton({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) { - const [ isReadOnly, setReadOnly ] = useNoteLabelBoolean(note, "readOnly"); - const isSavedSqlite = note.isTriliumSqlite() && !note.isHiddenCompletely(); - const isEnabled = ([ "mermaid", "mindMap", "canvas", "spreadsheet" ].includes(note.type) || isSavedSqlite) - && note.isContentAvailable() && isDefaultViewMode; +function DisplayModeSwitcher({ note, isDefaultViewMode }: NoteActionsCustomInnerProps) { + const [ displayMode, setDisplayMode ] = useNoteLabel(note, "displayMode"); + const isEnabled = (note.isMarkdown() || note.type === "mermaid") && note.isContentAvailable() && isDefaultViewMode; + if (!isEnabled) return null; - return isEnabled && setReadOnly(!isReadOnly)} - />; + const mode = displayMode === "source" || displayMode === "preview" ? displayMode : "split"; + const buttons: Array<{ value: "source" | "split" | "preview"; icon: string; text: string }> = [ + { value: "source", icon: "bx bx-code", text: t("display_mode.source") }, + { value: "split", icon: "bx bxs-dock-left", text: t("display_mode.split") }, + { value: "preview", icon: "bx bx-show", text: t("display_mode.preview") } + ]; + + if (cachedIsMobile) { + return ( +
+ {buttons.map(({ value, icon, text }) => ( + setDisplayMode(value)} + /> + ))} +
+ ); + } + + return ( + <> +
+ + {buttons.map(({ value, icon, text }) => ( + setDisplayMode(value)} + /> + ))} + +
+ + ); } function RunActiveNoteButton({ noteMime }: NoteActionsCustomInnerProps) { @@ -268,12 +307,12 @@ function AddChildButton({ parentComponent, noteType, ntxId, isReadOnly }: NoteAc } //#endregion -function NoteAction({ text, ...props }: Pick & { +function NoteAction({ text, active, ...props }: Pick & { onClick?: ((e: MouseEvent) => void) | undefined; }) { return (cachedIsMobile ? {text} - : + : ); } diff --git a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx index edae4f014f..1e5bb77500 100644 --- a/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx +++ b/apps/client/src/widgets/ribbon/components/AttributeEditor.tsx @@ -98,6 +98,8 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI const [ state, setState ] = useState<"normal" | "showHelpTooltip" | "showAttributeDetail">(); const [ error, setError ] = useState(); const [ needsSaving, setNeedsSaving ] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const suppressNextOnHide = useRef(false); const lastSavedContent = useRef(); const currentValueRef = useRef(currentValue); @@ -383,6 +385,12 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI onClick={(e) => { // Prevent automatic hiding of the context menu due to the button being clicked. e.stopPropagation(); + if (isMenuOpen) { + // If we re-show the menu, ContextMenu.show() will call hide() + // and immediately trigger onHide. Suppress that transient hide. + suppressNextOnHide.current = true; + } + setIsMenuOpen(true); contextMenu.show({ x: e.pageX, @@ -395,7 +403,14 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI { title: t("attribute_editor.add_new_label_definition"), command: "addNewLabelDefinition", uiIcon: "bx bx-empty" }, { title: t("attribute_editor.add_new_relation_definition"), command: "addNewRelationDefinition", uiIcon: "bx bx-empty" } ], - selectMenuItemHandler: (item) => handleAddNewAttributeCommand(item.command) + selectMenuItemHandler: (item) => handleAddNewAttributeCommand(item.command), + onHide: () => { + if (suppressNextOnHide.current) { + suppressNextOnHide.current = false; + return; + } + setIsMenuOpen(false); + }, }); }} /> @@ -438,4 +453,4 @@ function getClickIndex(pos: ModelPosition) { } return clickIndex; -} +} \ No newline at end of file diff --git a/apps/client/src/widgets/scroll_padding.tsx b/apps/client/src/widgets/scroll_padding.tsx index e277ee86a1..57d7349d05 100644 --- a/apps/client/src/widgets/scroll_padding.tsx +++ b/apps/client/src/widgets/scroll_padding.tsx @@ -9,7 +9,8 @@ export default function ScrollPadding() { const isEnabled = ["text", "code"].includes(note?.type ?? "") && viewScope?.viewMode === "default" && note?.isContentAvailable() - && !note?.isTriliumSqlite(); + && !note?.isTriliumSqlite() + && !note?.isMarkdown(); const refreshHeight = () => { if (!ref.current) return; diff --git a/apps/client/src/widgets/sidebar/TableOfContents.tsx b/apps/client/src/widgets/sidebar/TableOfContents.tsx index 4442af884c..d84670128d 100644 --- a/apps/client/src/widgets/sidebar/TableOfContents.tsx +++ b/apps/client/src/widgets/sidebar/TableOfContents.tsx @@ -1,6 +1,6 @@ import "./TableOfContents.css"; -import { attributeChangeAffectsHeading, CKTextEditor, ModelElement } from "@triliumnext/ckeditor5"; +import { attributeChangeAffectsHeading, CKTextEditor, ModelElement, type ModelNode } from "@triliumnext/ckeditor5"; import clsx from "clsx"; import { useCallback, useEffect, useRef, useState } from "preact/hooks"; @@ -230,7 +230,7 @@ function extractTocFromTextEditor(editor: CKTextEditor) { // Fallback to plain text if DOM conversion fails if (!text) { text = Array.from( item.getChildren() ) - .map( c => c.is( '$text' ) ? c.data : '' ) + .map( (c: ModelNode) => c.is( '$text' ) ? c.data : '' ) .join( '' ); } diff --git a/apps/client/src/widgets/type_widgets/Attachment.tsx b/apps/client/src/widgets/type_widgets/Attachment.tsx index 381313d6ba..851480dd7f 100644 --- a/apps/client/src/widgets/type_widgets/Attachment.tsx +++ b/apps/client/src/widgets/type_widgets/Attachment.tsx @@ -30,6 +30,7 @@ import Icon from "../react/Icon"; import Modal from "../react/Modal"; import NoteLink from "../react/NoteLink"; import { ParentComponent, refToJQuerySelector } from "../react/react_utils"; +import { TextPreview } from "./File"; import { TextRepresentation } from "./ReadOnlyTextRepresentation"; import { TypeWidgetProps } from "./type_widget"; @@ -144,6 +145,7 @@ export function AttachmentDetail({ note, viewScope }: TypeWidgetProps) { function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, isFullDetail?: boolean }) { const contentWrapper = useRef(null); const [ ocrModalShown, setOcrModalShown ] = useState(false); + const [ textContent, setTextContent ] = useState(null); const supportsOcr = attachment.role === "image" || attachment.role === "file"; function refresh() { @@ -151,6 +153,10 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment, .then(({ $renderedContent }) => { contentWrapper.current?.replaceChildren(...$renderedContent); }); + + if (attachment.role === "file") { + attachment.getBlob().then(blob => setTextContent(blob?.content ?? null)); + } } useEffect(refresh, [ attachment ]); @@ -213,6 +219,7 @@ function AttachmentInfo({ attachment, isFullDetail }: { attachment: FAttachment,
{attachment.utcDateScheduledForErasureSince && } + {textContent && }
diff --git a/apps/client/src/widgets/type_widgets/File.tsx b/apps/client/src/widgets/type_widgets/File.tsx index f36ecce854..2fa982a083 100644 --- a/apps/client/src/widgets/type_widgets/File.tsx +++ b/apps/client/src/widgets/type_widgets/File.tsx @@ -26,7 +26,7 @@ export default function FileTypeWidget({ note, parentComponent, noteContext }: T } -function TextPreview({ content }: { content: string }) { +export function TextPreview({ content }: { content: string }) { const trimmedContent = content.substring(0, TEXT_MAX_NUM_CHARS); const isTooLarge = trimmedContent.length !== content.length; diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx index d4a3ff1841..bf1f3382b1 100644 --- a/apps/client/src/widgets/type_widgets/MindMap.tsx +++ b/apps/client/src/widgets/type_widgets/MindMap.tsx @@ -12,7 +12,7 @@ import { HTMLAttributes, RefObject } from "preact"; import { useCallback, useEffect, useRef } from "preact/hooks"; import utils from "../../services/utils"; -import { useColorScheme, useEditorSpacedUpdate, useNoteLabelBoolean, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; +import { useColorScheme, useEditorSpacedUpdate, useEffectiveReadOnly, useSyncedRef, useTriliumEvent, useTriliumEvents, useTriliumOption } from "../react/hooks"; import { refToJQuerySelector } from "../react/react_utils"; import { TypeWidgetProps } from "./type_widget"; @@ -46,7 +46,7 @@ function buildMindElixirLangPack(): LangPack { export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { const apiRef = useRef(null); const containerRef = useRef(null); - const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); + const isReadOnly = useEffectiveReadOnly(note, noteContext); const spacedUpdate = useEditorSpacedUpdate({ diff --git a/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx b/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx index 3c22af9c76..6ffe65c43f 100644 --- a/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx +++ b/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx @@ -1,7 +1,7 @@ import { Excalidraw } from "@excalidraw/excalidraw"; import { TypeWidgetProps } from "../type_widget"; import "@excalidraw/excalidraw/index.css"; -import { useColorScheme, useNoteLabelBoolean, useTriliumOption } from "../../react/hooks"; +import { useColorScheme, useEffectiveReadOnly, useTriliumOption } from "../../react/hooks"; import { useCallback, useMemo, useRef } from "preact/hooks"; import { type ExcalidrawImperativeAPI, type AppState } from "@excalidraw/excalidraw/types"; import options from "../../../services/options"; @@ -18,7 +18,7 @@ window.EXCALIDRAW_ASSET_PATH = `${window.location.pathname}/node_modules/@excali export default function Canvas({ note, noteContext }: TypeWidgetProps) { const apiRef = useRef(null); - const [ isReadOnly ] = useNoteLabelBoolean(note, "readOnly"); + const isReadOnly = useEffectiveReadOnly(note, noteContext); const colorScheme = useColorScheme(); const [ locale ] = useTriliumOption("locale"); const persistence = useCanvasPersistence(note, noteContext, apiRef, colorScheme, isReadOnly); diff --git a/apps/client/src/widgets/type_widgets/code/Code.tsx b/apps/client/src/widgets/type_widgets/code/Code.tsx index 4f34edd96b..6c74dc8219 100644 --- a/apps/client/src/widgets/type_widgets/code/Code.tsx +++ b/apps/client/src/widgets/type_widgets/code/Code.tsx @@ -2,6 +2,7 @@ import "./code.css"; import { default as VanillaCodeMirror, getThemeById } from "@triliumnext/codemirror"; import { NoteType } from "@triliumnext/commons"; +import { Ref } from "preact"; import { useEffect, useRef, useState } from "preact/hooks"; import appContext, { CommandListenerData } from "../../../components/app_context"; @@ -31,9 +32,11 @@ export interface EditableCodeProps extends TypeWidgetProps, Omit void; placeholder?: string; + /** Optional external ref to the underlying CodeMirror `EditorView`. Populated once the editor has initialized. */ + editorRef?: Ref; } -export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWidgetProps) { +export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent, editorRef }: TypeWidgetProps & { editorRef?: Ref }) { const [ content, setContent ] = useState(""); const blob = useNoteBlob(note); const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth"); @@ -54,6 +57,7 @@ export function ReadOnlyCode({ note, viewScope, ntxId, parentComponent }: TypeWi return ( (null); const containerRef = useRef(null); + const combinedEditorRef = (view: VanillaCodeMirror | null) => { + editorRef.current = view; + if (typeof externalEditorRef === "function") externalEditorRef(view); + else if (externalEditorRef) externalEditorRef.current = view; + }; const [ vimKeymapEnabled ] = useTriliumOptionBool("vimKeymapEnabled"); const [ noteTabWidth ] = useNoteLabelInt(note, "tabWidth"); const [ noteUseTabs ] = useNoteLabelOptionalBool(note, "indentWithTabs"); @@ -122,7 +131,7 @@ export function EditableCode({ note, ntxId, noteContext, debounceUpdate, parentC <> { - if (externalContainerRef && containerRef.current) { - externalContainerRef.current = containerRef.current; + if (containerRef.current) { + if (typeof externalContainerRef === "function") externalContainerRef(containerRef.current); + else if (externalContainerRef) externalContainerRef.current = containerRef.current; } - if (externalEditorRef && codeEditorRef.current) { - externalEditorRef.current = codeEditorRef.current; + if (codeEditorRef.current) { + if (typeof externalEditorRef === "function") externalEditorRef(codeEditorRef.current); + else if (externalEditorRef) externalEditorRef.current = codeEditorRef.current; } initialized.current.resolve(); onInitialized?.(); diff --git a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx index c52c8e7676..e80a0202a2 100644 --- a/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx +++ b/apps/client/src/widgets/type_widgets/code/CodeMirror.tsx @@ -1,14 +1,14 @@ import { useEffect, useRef } from "preact/hooks"; import { EditorConfig, default as VanillaCodeMirror } from "@triliumnext/codemirror"; import { useSyncedRef } from "../../react/hooks"; -import { RefObject } from "preact"; +import { Ref } from "preact"; export interface CodeMirrorProps extends Omit { content?: string; mime: string; className?: string; - editorRef?: RefObject; - containerRef?: RefObject; + editorRef?: Ref; + containerRef?: Ref; onInitialized?: () => void; } @@ -25,9 +25,8 @@ export default function CodeMirror({ className, content, mime, editorRef: extern ...extraOpts }); codeEditorRef.current = codeEditor; - if (externalEditorRef) { - externalEditorRef.current = codeEditor; - } + if (typeof externalEditorRef === "function") externalEditorRef(codeEditor); + else if (externalEditorRef) externalEditorRef.current = codeEditor; onInitialized?.(); return () => codeEditor.destroy(); diff --git a/apps/client/src/widgets/type_widgets/code/Markdown.css b/apps/client/src/widgets/type_widgets/code/Markdown.css new file mode 100644 index 0000000000..30edf72ffe --- /dev/null +++ b/apps/client/src/widgets/type_widgets/code/Markdown.css @@ -0,0 +1,34 @@ +.note-detail-markdown { + .note-detail-code-editor { + margin-top: 0 !important; + + .cm-editor .cm-scroller { + padding-block: 0.25em; + } + } + + .markdown-preview { + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 0.5em 1em; + user-select: text; + + .mermaid-diagram, + .mermaid-diagram svg { + max-width: 100%; + } + } + + .markdown-preview [data-source-line] { + border-left: 2px solid transparent; + padding-left: 0.5em; + margin-left: -0.5em; + } + + [data-source-line].markdown-preview-active { + border-left-color: var(--main-text-color); + } + +} + diff --git a/apps/client/src/widgets/type_widgets/code/Markdown.spec.ts b/apps/client/src/widgets/type_widgets/code/Markdown.spec.ts new file mode 100644 index 0000000000..0addb5037a --- /dev/null +++ b/apps/client/src/widgets/type_widgets/code/Markdown.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; + +import { renderWithSourceLines } from "./Markdown.js"; + +describe("renderWithSourceLines", () => { + function extractLines(html: string): number[] { + return [ ...html.matchAll(/data-source-line="(\d+)"/g) ].map((m) => parseInt(m[1], 10)); + } + + it("returns empty string for empty input", () => { + expect(renderWithSourceLines("")).toBe(""); + }); + + it("tags a single block as line 1", () => { + const html = renderWithSourceLines("hello"); + expect(extractLines(html)).toEqual([ 1 ]); + expect(html).toContain("hello"); + }); + + it("assigns correct source lines to consecutive blocks separated by blank lines", () => { + const src = [ + "# Heading", // line 1 + "", // line 2 + "A paragraph.", // line 3 + "", // line 4 + "Another one." // line 5 + ].join("\n"); + + expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 3, 5 ]); + }); + + it("counts multi-line blocks so subsequent blocks get the right line", () => { + const src = [ + "```", // 1 + "code", // 2 + "more code", // 3 + "```", // 4 + "", // 5 + "after" // 6 + ].join("\n"); + + expect(extractLines(renderWithSourceLines(src))).toEqual([ 1, 6 ]); + }); + + it("renders standard markdown constructs inside the wrappers", () => { + const html = renderWithSourceLines("## Heading\n\n- item\n"); + expect(html).toContain("

Heading

"); + expect(html).toContain("
    "); + expect(html).toContain("
  • item
  • "); + }); + + it("keeps H1 as H1 in the preview (no title-row context to avoid)", () => { + const html = renderWithSourceLines("# Top level"); + expect(html).toContain("

    Top level

    "); + }); + + it("preserves reference-style links across per-block parsing", () => { + const src = [ + "[trilium][t]", // 1 + "", // 2 + "[t]: https://example.com" + ].join("\n"); + + const html = renderWithSourceLines(src); + expect(html).toContain('href="https://example.com"'); + }); + + it("normalizes fenced code languages to CKEditor MIME identifiers for syntax highlighting", () => { + const html = renderWithSourceLines("```javascript\nconst x = 1;\n```"); + expect(html).toMatch(/class="language-application-javascript-env-(backend|frontend)"/); + }); + + it("produces CKEditor admonition markup for GFM callouts", () => { + const html = renderWithSourceLines("> [!NOTE]\n> heads up"); + expect(html).toContain('