From af8a9051507391231d566fab5545e8de41b6826a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 13:34:17 +0300 Subject: [PATCH 01/16] feat(text-snippets): basic integration --- .../widgets/type_widgets/ckeditor/config.ts | 6 +++++- .../widgets/type_widgets/ckeditor/snippets.ts | 19 +++++++++++++++++++ .../src/widgets/type_widgets/editable_text.ts | 2 +- packages/ckeditor5/src/index.ts | 1 + packages/ckeditor5/src/plugins.ts | 5 +++-- 5 files changed, 29 insertions(+), 4 deletions(-) create mode 100644 apps/client/src/widgets/type_widgets/ckeditor/snippets.ts diff --git a/apps/client/src/widgets/type_widgets/ckeditor/config.ts b/apps/client/src/widgets/type_widgets/ckeditor/config.ts index 39b3261b3..0cf6b88ce 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/config.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/config.ts @@ -7,13 +7,14 @@ import { ensureMimeTypesForHighlighting, isSyntaxHighlightEnabled } from "../../ import utils from "../../../services/utils.js"; import emojiDefinitionsUrl from "@triliumnext/ckeditor5/emoji_definitions/en.json?url"; import { copyTextWithToast } from "../../../services/clipboard_ext.js"; +import getTemplates from "./snippets.js"; const TEXT_FORMATTING_GROUP = { label: "Text formatting", icon: "text" }; -export function buildConfig(): EditorConfig { +export async function buildConfig(): Promise { return { image: { styles: { @@ -126,6 +127,9 @@ export function buildConfig(): EditorConfig { dropdownLimit: Number.MAX_SAFE_INTEGER, extraCommands: buildExtraCommands() }, + template: { + definitions: await getTemplates() + }, // This value must be kept in sync with the language defined in webpack.config.js. language: "en" }; diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts new file mode 100644 index 000000000..31be5ae24 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -0,0 +1,19 @@ +import search from "../../../services/search"; +import type { TemplateDefinition } from "@triliumnext/ckeditor5"; + +/** + * Generates the list of snippets based on the user's notes to be passed down to the CKEditor configuration. + * + * @returns the list of templates. + */ +export default async function getTemplates() { + const definitions: TemplateDefinition[] = []; + const snippets = await search.searchForNotes("#textSnippet"); + for (const snippet of snippets) { + definitions.push({ + title: snippet.title, + data: await snippet.getContent() ?? "" + }) + } + return definitions; +} diff --git a/apps/client/src/widgets/type_widgets/editable_text.ts b/apps/client/src/widgets/type_widgets/editable_text.ts index 329e59824..7a48e81ef 100644 --- a/apps/client/src/widgets/type_widgets/editable_text.ts +++ b/apps/client/src/widgets/type_widgets/editable_text.ts @@ -193,7 +193,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const finalConfig = { ...editorConfig, - ...buildConfig(), + ...(await buildConfig()), ...buildToolbarConfig(isClassicEditor), htmlSupport: { allow: JSON.parse(options.get("allowedHtmlTags")), diff --git a/packages/ckeditor5/src/index.ts b/packages/ckeditor5/src/index.ts index 0ddd55d60..4144754a5 100644 --- a/packages/ckeditor5/src/index.ts +++ b/packages/ckeditor5/src/index.ts @@ -4,6 +4,7 @@ import { COMMON_PLUGINS, CORE_PLUGINS, POPUP_EDITOR_PLUGINS } from "./plugins"; import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5"; export { EditorWatchdog } from "ckeditor5"; export type { EditorConfig, MentionFeed, MentionFeedObjectItem, Node, Position, Element, WatchdogConfig } from "ckeditor5"; +export type { TemplateDefinition } from "ckeditor5-premium-features"; export { default as buildExtraCommands } from "./extra_slash_commands.js"; // Import with sideffects to ensure that type augmentations are present. diff --git a/packages/ckeditor5/src/plugins.ts b/packages/ckeditor5/src/plugins.ts index 391932c5e..1911c9e64 100644 --- a/packages/ckeditor5/src/plugins.ts +++ b/packages/ckeditor5/src/plugins.ts @@ -1,5 +1,5 @@ import { Autoformat, AutoLink, BlockQuote, BlockToolbar, Bold, CKFinderUploadAdapter, Clipboard, Code, CodeBlock, Enter, FindAndReplace, Font, FontBackgroundColor, FontColor, GeneralHtmlSupport, Heading, HeadingButtonsUI, HorizontalLine, Image, ImageCaption, ImageInline, ImageResize, ImageStyle, ImageToolbar, ImageUpload, Alignment, Indent, IndentBlock, Italic, Link, List, ListProperties, Mention, PageBreak, Paragraph, ParagraphButtonUI, PasteFromOffice, PictureEditing, RemoveFormat, SelectAll, ShiftEnter, SpecialCharacters, SpecialCharactersEssentials, Strikethrough, Style, Subscript, Superscript, Table, TableCaption, TableCellProperties, TableColumnResize, TableProperties, TableSelection, TableToolbar, TextPartLanguage, TextTransformation, TodoList, Typing, Underline, Undo, Bookmark, Emoji } from "ckeditor5"; -import { SlashCommand } from "ckeditor5-premium-features"; +import { SlashCommand, Template } from "ckeditor5-premium-features"; import type { Plugin } from "ckeditor5"; import CutToNotePlugin from "./plugins/cuttonote.js"; import UploadimagePlugin from "./plugins/uploadimage.js"; @@ -82,7 +82,8 @@ export const CORE_PLUGINS: typeof Plugin[] = [ * Plugins that require a premium CKEditor license key to work. */ export const PREMIUM_PLUGINS: typeof Plugin[] = [ - SlashCommand + SlashCommand, + Template ]; /** From 502638bae78eef94a228cf6096c6084953b3b23a Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 13:39:17 +0300 Subject: [PATCH 02/16] feat(text-snippets): add toolbar entry --- apps/client/src/widgets/type_widgets/ckeditor/config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/client/src/widgets/type_widgets/ckeditor/config.ts b/apps/client/src/widgets/type_widgets/ckeditor/config.ts index 0cf6b88ce..03bd5fc07 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/config.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/config.ts @@ -210,6 +210,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) { "outdent", "indent", "|", + "insertTemplate", "markdownImport", "cuttonote", "findAndReplace" @@ -266,6 +267,7 @@ export function buildFloatingToolbar() { "outdent", "indent", "|", + "insertTemplate", "imageUpload", "markdownImport", "specialCharacters", From 17ede00fb2e3ef24a9dc754ac30efd88e388f5b1 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 13:56:29 +0300 Subject: [PATCH 03/16] feat(text-snippets): reload editors when templates change --- .../src/widgets/type_widgets/editable_text.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/editable_text.ts b/apps/client/src/widgets/type_widgets/editable_text.ts index 7a48e81ef..aa7902776 100644 --- a/apps/client/src/widgets/type_widgets/editable_text.ts +++ b/apps/client/src/widgets/type_widgets/editable_text.ts @@ -326,7 +326,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const data = blob?.content || ""; const newContentLanguage = this.note?.getLabelValue("language"); if (this.contentLanguage !== newContentLanguage) { - await this.reinitialize(data); + await this.reinitializeWithData(data); } else { this.watchdog.editor?.setData(data); } @@ -562,7 +562,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { this.refreshIncludedNote(this.$editor, noteId); } - async reinitialize(data: string) { + async reinitializeWithData(data: string) { if (!this.watchdog) { return; } @@ -572,9 +572,23 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { this.watchdog.editor?.setData(data); } - async onLanguageChanged() { + async reinitialize() { const data = this.watchdog.editor?.getData(); - await this.reinitialize(data ?? ""); + await this.reinitializeWithData(data ?? ""); + } + + async onLanguageChanged() { + await this.reinitialize(); + } + + async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) { + await super.entitiesReloadedEvent(e); + + if (e.loadResults.getAttributeRows().find((attr) => + attr.type === "label" && + attr.name === "textSnippet")) { + await this.reinitialize(); + } } buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) { From 421e12588241da0d80e496a29a8d06f998e70c2d Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 16:36:05 +0300 Subject: [PATCH 04/16] feat(text-snippets): handle content changes --- .../widgets/type_widgets/ckeditor/snippets.ts | 43 +++++++++++++++++-- .../src/widgets/type_widgets/editable_text.ts | 5 +-- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index 31be5ae24..9072194a5 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -1,19 +1,56 @@ -import search from "../../../services/search"; +import froca from "../../../services/froca.js"; +import type LoadResults from "../../../services/load_results.js"; +import search from "../../../services/search.js"; import type { TemplateDefinition } from "@triliumnext/ckeditor5"; +let templateCache: Record = {}; + /** * Generates the list of snippets based on the user's notes to be passed down to the CKEditor configuration. * * @returns the list of templates. */ export default async function getTemplates() { - const definitions: TemplateDefinition[] = []; + // Build the definitions and populate the cache. const snippets = await search.searchForNotes("#textSnippet"); + const definitions: TemplateDefinition[] = []; for (const snippet of snippets) { + templateCache[snippet.noteId] = await (snippet.getContent()) ?? ""; + definitions.push({ title: snippet.title, - data: await snippet.getContent() ?? "" + data: () => templateCache[snippet.noteId] }) } return definitions; } + +async function handleUpdate(affectedNoteIds: string[]) { + const updatedNoteIds = new Set(affectedNoteIds); + const templateNoteIds = new Set(Object.keys(templateCache)); + const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds); + + console.log("Got ", affectedTemplateNoteIds); + + await froca.getNotes(affectedNoteIds); + + for (const affectedTemplateNoteId of affectedTemplateNoteIds) { + const template = await froca.getNote(affectedTemplateNoteId); + if (!template) { + console.warn("Unable to obtain template with ID ", affectedTemplateNoteId); + continue; + } + + templateCache[affectedTemplateNoteId] = await template.getContent() ?? ""; + } +} + +export function updateTemplateCache(loadResults: LoadResults): boolean { + const affectedNoteIds = loadResults.getNoteIds(); + if (affectedNoteIds.length > 0) { + handleUpdate(affectedNoteIds); + } + + + return false; +} diff --git a/apps/client/src/widgets/type_widgets/editable_text.ts b/apps/client/src/widgets/type_widgets/editable_text.ts index aa7902776..824b5c320 100644 --- a/apps/client/src/widgets/type_widgets/editable_text.ts +++ b/apps/client/src/widgets/type_widgets/editable_text.ts @@ -18,6 +18,7 @@ import { getMermaidConfig } from "../../services/mermaid.js"; import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig } from "@triliumnext/ckeditor5"; import "@triliumnext/ckeditor5/index.css"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; +import { updateTemplateCache } from "./ckeditor/snippets.js"; const mentionSetup: MentionFeed[] = [ { @@ -584,9 +585,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) { await super.entitiesReloadedEvent(e); - if (e.loadResults.getAttributeRows().find((attr) => - attr.type === "label" && - attr.name === "textSnippet")) { + if (updateTemplateCache(e.loadResults)) { await this.reinitialize(); } } From 9f82e0a6d6e6ea3e375fd03a0d38082847806553 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 16:48:30 +0300 Subject: [PATCH 05/16] refactor(text-snippets): use a map instead of an object --- .../src/widgets/type_widgets/ckeditor/snippets.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index 9072194a5..a24aefaf8 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -3,7 +3,7 @@ import type LoadResults from "../../../services/load_results.js"; import search from "../../../services/search.js"; import type { TemplateDefinition } from "@triliumnext/ckeditor5"; -let templateCache: Record = {}; +let templateCache: Map = new Map(); /** * Generates the list of snippets based on the user's notes to be passed down to the CKEditor configuration. @@ -15,11 +15,11 @@ export default async function getTemplates() { const snippets = await search.searchForNotes("#textSnippet"); const definitions: TemplateDefinition[] = []; for (const snippet of snippets) { - templateCache[snippet.noteId] = await (snippet.getContent()) ?? ""; + templateCache.set(snippet.noteId, await snippet.getContent()); definitions.push({ title: snippet.title, - data: () => templateCache[snippet.noteId] + data: () => templateCache.get(snippet.noteId) ?? "" }) } return definitions; @@ -27,7 +27,7 @@ export default async function getTemplates() { async function handleUpdate(affectedNoteIds: string[]) { const updatedNoteIds = new Set(affectedNoteIds); - const templateNoteIds = new Set(Object.keys(templateCache)); + const templateNoteIds = new Set(templateCache.keys()); const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds); console.log("Got ", affectedTemplateNoteIds); @@ -41,7 +41,7 @@ async function handleUpdate(affectedNoteIds: string[]) { continue; } - templateCache[affectedTemplateNoteId] = await template.getContent() ?? ""; + templateCache.set(affectedTemplateNoteId, await template.getContent()); } } From fb1a74a96d42a5f5cc7a58e2af0849bed12471c9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 16:51:27 +0300 Subject: [PATCH 06/16] feat(text-snippets): debounce updates to avoid duplication --- apps/client/src/widgets/type_widgets/ckeditor/snippets.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index a24aefaf8..72a769015 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -1,9 +1,11 @@ +import debounce from "debounce"; import froca from "../../../services/froca.js"; import type LoadResults from "../../../services/load_results.js"; import search from "../../../services/search.js"; import type { TemplateDefinition } from "@triliumnext/ckeditor5"; let templateCache: Map = new Map(); +const debouncedHandleUpdate = debounce(handleUpdate, 1000); /** * Generates the list of snippets based on the user's notes to be passed down to the CKEditor configuration. @@ -48,7 +50,7 @@ async function handleUpdate(affectedNoteIds: string[]) { export function updateTemplateCache(loadResults: LoadResults): boolean { const affectedNoteIds = loadResults.getNoteIds(); if (affectedNoteIds.length > 0) { - handleUpdate(affectedNoteIds); + debouncedHandleUpdate(affectedNoteIds); } From 97799bfaccdc6e0deb6d9e7472fb6feee7699bc8 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 17:03:23 +0300 Subject: [PATCH 07/16] feat(text-snippets): handle renames by refreshing the editor --- apps/client/src/components/app_context.ts | 1 + .../widgets/type_widgets/ckeditor/snippets.ts | 38 +++++++++++++++---- .../src/widgets/type_widgets/editable_text.ts | 4 ++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 3c7873e8b..0cf5058ed 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -281,6 +281,7 @@ export type CommandMappings = { buildIcon(name: string): NativeImage; }; refreshTouchBar: CommandData; + reloadTextEditor: CommandData; }; type EventMappings = { diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index 72a769015..f2f747004 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -3,9 +3,15 @@ import froca from "../../../services/froca.js"; import type LoadResults from "../../../services/load_results.js"; import search from "../../../services/search.js"; import type { TemplateDefinition } from "@triliumnext/ckeditor5"; +import appContext from "../../../components/app_context.js"; -let templateCache: Map = new Map(); -const debouncedHandleUpdate = debounce(handleUpdate, 1000); +interface TemplateData { + title: string; + content: string | undefined; +} + +let templateCache: Map = new Map(); +const debouncedHandleContentUpdate = debounce(handleContentUpdate, 1000); /** * Generates the list of snippets based on the user's notes to be passed down to the CKEditor configuration. @@ -17,17 +23,20 @@ export default async function getTemplates() { const snippets = await search.searchForNotes("#textSnippet"); const definitions: TemplateDefinition[] = []; for (const snippet of snippets) { - templateCache.set(snippet.noteId, await snippet.getContent()); + templateCache.set(snippet.noteId, { + title: snippet.title, + content: await snippet.getContent() + }); definitions.push({ title: snippet.title, - data: () => templateCache.get(snippet.noteId) ?? "" + data: () => templateCache.get(snippet.noteId)?.content ?? "" }) } return definitions; } -async function handleUpdate(affectedNoteIds: string[]) { +async function handleContentUpdate(affectedNoteIds: string[]) { const updatedNoteIds = new Set(affectedNoteIds); const templateNoteIds = new Set(templateCache.keys()); const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds); @@ -36,6 +45,7 @@ async function handleUpdate(affectedNoteIds: string[]) { await froca.getNotes(affectedNoteIds); + let fullReloadNeeded = false; for (const affectedTemplateNoteId of affectedTemplateNoteIds) { const template = await froca.getNote(affectedTemplateNoteId); if (!template) { @@ -43,14 +53,28 @@ async function handleUpdate(affectedNoteIds: string[]) { continue; } - templateCache.set(affectedTemplateNoteId, await template.getContent()); + const newTitle = template.title; + if (templateCache.get(affectedTemplateNoteId)?.title !== newTitle) { + fullReloadNeeded = true; + break; + } + + templateCache.set(affectedTemplateNoteId, { + title: template.title, + content: await template.getContent() + }); + } + + if (fullReloadNeeded) { + console.warn("Full text editor reload needed"); + appContext.triggerCommand("reloadTextEditor"); } } export function updateTemplateCache(loadResults: LoadResults): boolean { const affectedNoteIds = loadResults.getNoteIds(); if (affectedNoteIds.length > 0) { - debouncedHandleUpdate(affectedNoteIds); + debouncedHandleContentUpdate(affectedNoteIds); } diff --git a/apps/client/src/widgets/type_widgets/editable_text.ts b/apps/client/src/widgets/type_widgets/editable_text.ts index 824b5c320..28094f2a2 100644 --- a/apps/client/src/widgets/type_widgets/editable_text.ts +++ b/apps/client/src/widgets/type_widgets/editable_text.ts @@ -578,6 +578,10 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { await this.reinitializeWithData(data ?? ""); } + async reloadTextEditorEvent() { + await this.reinitialize(); + } + async onLanguageChanged() { await this.reinitialize(); } From 3e40a35c1948649125c2e85a37138ada229fa12e Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 17:13:11 +0300 Subject: [PATCH 08/16] feat(text-snippets): reload when a new template is added --- .../src/widgets/type_widgets/ckeditor/snippets.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index f2f747004..2aac02c75 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -36,6 +36,11 @@ export default async function getTemplates() { return definitions; } +function handleFullReload() { + console.warn("Full text editor reload needed"); + appContext.triggerCommand("reloadTextEditor"); +} + async function handleContentUpdate(affectedNoteIds: string[]) { const updatedNoteIds = new Set(affectedNoteIds); const templateNoteIds = new Set(templateCache.keys()); @@ -66,8 +71,7 @@ async function handleContentUpdate(affectedNoteIds: string[]) { } if (fullReloadNeeded) { - console.warn("Full text editor reload needed"); - appContext.triggerCommand("reloadTextEditor"); + handleFullReload(); } } @@ -77,6 +81,11 @@ export function updateTemplateCache(loadResults: LoadResults): boolean { debouncedHandleContentUpdate(affectedNoteIds); } + if (loadResults.getAttributeRows().find((attr) => + attr.type === "label" && + attr.name === "textSnippet")) { + handleFullReload(); + } return false; } From 4f9bd970afd525fde9a71e216ca4d5e7b9d96180 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 17:19:50 +0300 Subject: [PATCH 09/16] feat(text-snippets): better reaction to removing templates --- .../widgets/type_widgets/ckeditor/snippets.ts | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index 2aac02c75..2c0870c60 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -46,28 +46,31 @@ async function handleContentUpdate(affectedNoteIds: string[]) { const templateNoteIds = new Set(templateCache.keys()); const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds); - console.log("Got ", affectedTemplateNoteIds); - await froca.getNotes(affectedNoteIds); let fullReloadNeeded = false; for (const affectedTemplateNoteId of affectedTemplateNoteIds) { - const template = await froca.getNote(affectedTemplateNoteId); - if (!template) { - console.warn("Unable to obtain template with ID ", affectedTemplateNoteId); - continue; - } + try { + const template = await froca.getNote(affectedTemplateNoteId); + if (!template) { + console.warn("Unable to obtain template with ID ", affectedTemplateNoteId); + continue; + } - const newTitle = template.title; - if (templateCache.get(affectedTemplateNoteId)?.title !== newTitle) { + const newTitle = template.title; + if (templateCache.get(affectedTemplateNoteId)?.title !== newTitle) { + fullReloadNeeded = true; + break; + } + + templateCache.set(affectedTemplateNoteId, { + title: template.title, + content: await template.getContent() + }); + } catch (e) { + // If a note was not found while updating the cache, it means we need to do a full reload. fullReloadNeeded = true; - break; } - - templateCache.set(affectedTemplateNoteId, { - title: template.title, - content: await template.getContent() - }); } if (fullReloadNeeded) { @@ -77,14 +80,15 @@ async function handleContentUpdate(affectedNoteIds: string[]) { export function updateTemplateCache(loadResults: LoadResults): boolean { const affectedNoteIds = loadResults.getNoteIds(); - if (affectedNoteIds.length > 0) { - debouncedHandleContentUpdate(affectedNoteIds); - } + // React to creation or deletion of text snippets. if (loadResults.getAttributeRows().find((attr) => attr.type === "label" && attr.name === "textSnippet")) { handleFullReload(); + } else if (affectedNoteIds.length > 0) { + // Update content and titles if one of the template notes were updated. + debouncedHandleContentUpdate(affectedNoteIds); } return false; From 59e0857bb5b40e48cc27744a73a582f80cb390a9 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 17:32:28 +0300 Subject: [PATCH 10/16] feat(text-snippets): add default icon for templates --- apps/client/src/types-assets.d.ts | 5 +++++ apps/client/src/widgets/type_widgets/ckeditor/snippets.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/client/src/types-assets.d.ts b/apps/client/src/types-assets.d.ts index 010ec6b44..e80532517 100644 --- a/apps/client/src/types-assets.d.ts +++ b/apps/client/src/types-assets.d.ts @@ -8,4 +8,9 @@ declare module "*?url" { export default path; } +declare module "*?raw" { + var content: string; + export default content; +} + declare module "boxicons/css/boxicons.min.css" { } diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index 2c0870c60..74cf2b0fd 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -4,6 +4,7 @@ import type LoadResults from "../../../services/load_results.js"; import search from "../../../services/search.js"; import type { TemplateDefinition } from "@triliumnext/ckeditor5"; import appContext from "../../../components/app_context.js"; +import TemplateIcon from "@ckeditor/ckeditor5-icons/theme/icons/template.svg?raw"; interface TemplateData { title: string; @@ -30,7 +31,8 @@ export default async function getTemplates() { definitions.push({ title: snippet.title, - data: () => templateCache.get(snippet.noteId)?.content ?? "" + data: () => templateCache.get(snippet.noteId)?.content ?? "", + icon: TemplateIcon }) } return definitions; From 7e399cc10c31808406fc5f2563e5340581a39447 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 17:41:12 +0300 Subject: [PATCH 11/16] feat(text-snippets): support description --- .../widgets/type_widgets/ckeditor/snippets.ts | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts index 74cf2b0fd..b2d99cfd6 100644 --- a/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -5,10 +5,12 @@ import search from "../../../services/search.js"; import type { TemplateDefinition } from "@triliumnext/ckeditor5"; import appContext from "../../../components/app_context.js"; import TemplateIcon from "@ckeditor/ckeditor5-icons/theme/icons/template.svg?raw"; +import type FNote from "../../../entities/fnote.js"; interface TemplateData { title: string; - content: string | undefined; + description?: string; + content?: string; } let templateCache: Map = new Map(); @@ -24,20 +26,29 @@ export default async function getTemplates() { const snippets = await search.searchForNotes("#textSnippet"); const definitions: TemplateDefinition[] = []; for (const snippet of snippets) { - templateCache.set(snippet.noteId, { - title: snippet.title, - content: await snippet.getContent() - }); + const { description } = await invalidateCacheFor(snippet); definitions.push({ title: snippet.title, data: () => templateCache.get(snippet.noteId)?.content ?? "", - icon: TemplateIcon - }) + icon: TemplateIcon, + description + }); } return definitions; } +async function invalidateCacheFor(snippet: FNote) { + const description = snippet.getLabelValue("textSnippetDescription"); + const data: TemplateData = { + title: snippet.title, + description: description ?? undefined, + content: await snippet.getContent() + }; + templateCache.set(snippet.noteId, data); + return data; +} + function handleFullReload() { console.warn("Full text editor reload needed"); appContext.triggerCommand("reloadTextEditor"); @@ -65,10 +76,7 @@ async function handleContentUpdate(affectedNoteIds: string[]) { break; } - templateCache.set(affectedTemplateNoteId, { - title: template.title, - content: await template.getContent() - }); + await invalidateCacheFor(template); } catch (e) { // If a note was not found while updating the cache, it means we need to do a full reload. fullReloadNeeded = true; @@ -86,7 +94,7 @@ export function updateTemplateCache(loadResults: LoadResults): boolean { // React to creation or deletion of text snippets. if (loadResults.getAttributeRows().find((attr) => attr.type === "label" && - attr.name === "textSnippet")) { + (attr.name === "textSnippet" || attr.name === "textSnippetDescription"))) { handleFullReload(); } else if (affectedNoteIds.length > 0) { // Update content and titles if one of the template notes were updated. From fa112956933b44e4f401862e8cc618c2233bf035 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 18:36:20 +0300 Subject: [PATCH 12/16] feat(templates): add support for built-in templates --- apps/client/src/services/note_types.ts | 30 +++++++++++++++++++ apps/server/src/services/hidden_subtree.ts | 4 ++- .../src/services/hidden_subtree_templates.ts | 28 +++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/services/hidden_subtree_templates.ts diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index 4de0bd8bf..b8d057869 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -4,6 +4,8 @@ import { t } from "./i18n.js"; import type { MenuItem } from "../menus/context_menu.js"; import type { TreeCommandNames } from "../menus/tree_context_menu.js"; +const SEPARATOR = { title: "----" }; + async function getNoteTypeItems(command?: TreeCommandNames) { const items: MenuItem[] = [ { title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" }, @@ -18,6 +20,7 @@ async function getNoteTypeItems(command?: TreeCommandNames) { { title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" }, { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" }, { title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" }, + ...await getBuiltInTemplates(command) ]; const templateNoteIds = await server.get("search-templates"); @@ -40,6 +43,33 @@ async function getNoteTypeItems(command?: TreeCommandNames) { return items; } +async function getBuiltInTemplates(command?: TreeCommandNames) { + const templatesRoot = await froca.getNote("_templates"); + if (!templatesRoot) { + console.warn("Unable to find template root."); + return []; + } + + const childNotes = await templatesRoot.getChildNotes(); + if (childNotes.length === 0) { + return []; + } + + const items: MenuItem[] = [ + SEPARATOR + ]; + for (const templateNote of childNotes) { + items.push({ + title: templateNote.title, + uiIcon: templateNote.getIcon(), + command: command, + type: templateNote.type, + templateNoteId: templateNote.noteId + }); + } + return items; +} + export default { getNoteTypeItems }; diff --git a/apps/server/src/services/hidden_subtree.ts b/apps/server/src/services/hidden_subtree.ts index 02c7b90f0..6ba0d965b 100644 --- a/apps/server/src/services/hidden_subtree.ts +++ b/apps/server/src/services/hidden_subtree.ts @@ -8,6 +8,7 @@ import migrationService from "./migration.js"; import { t } from "i18next"; import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js"; import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js"; +import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js"; const LBTPL_ROOT = "_lbTplRoot"; const LBTPL_BASE = "_lbTplBase"; @@ -257,7 +258,8 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS icon: "bx-help-circle", children: helpSubtree, isExpanded: true - } + }, + buildHiddenSubtreeTemplates() ] }; } diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/apps/server/src/services/hidden_subtree_templates.ts new file mode 100644 index 000000000..8e4a628a4 --- /dev/null +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -0,0 +1,28 @@ +import { HiddenSubtreeItem } from "@triliumnext/commons"; + +export default function buildHiddenSubtreeTemplates() { + const templates: HiddenSubtreeItem = { + id: "_templates", + title: "Built-in templates", + type: "book", + children: [ + { + id: "_template_text_snippet", + type: "text", + title: "Text Snippet", + attributes: [ + { + name: "template", + type: "label" + }, + { + name: "textSnippet", + type: "label" + } + ] + } + ] + }; + + return templates; +} From 9687a9d8ff3e57456d000f2831fb854390e034e4 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 18:39:37 +0300 Subject: [PATCH 13/16] refactor(note_types): separate user templates into own method --- apps/client/src/services/froca.ts | 4 +++ apps/client/src/services/note_types.ts | 34 +++++++++++++++----------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/apps/client/src/services/froca.ts b/apps/client/src/services/froca.ts index 131cec06f..6bbc3a50d 100644 --- a/apps/client/src/services/froca.ts +++ b/apps/client/src/services/froca.ts @@ -245,6 +245,10 @@ class FrocaImpl implements Froca { } async getNotes(noteIds: string[] | JQuery, silentNotFoundError = false): Promise { + if (noteIds.length === 0) { + return []; + } + noteIds = Array.from(new Set(noteIds)); // make unique const missingNoteIds = noteIds.filter((noteId) => !this.notes[noteId]); diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index b8d057869..e9905063c 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -20,26 +20,32 @@ async function getNoteTypeItems(command?: TreeCommandNames) { { title: t("note_types.web-view"), command, type: "webView", uiIcon: "bx bx-globe-alt" }, { title: t("note_types.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" }, { title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" }, - ...await getBuiltInTemplates(command) + ...await getBuiltInTemplates(command), + ...await getUserTemplates(command) ]; + return items; +} + +async function getUserTemplates(command?: TreeCommandNames) { const templateNoteIds = await server.get("search-templates"); const templateNotes = await froca.getNotes(templateNoteIds); - - if (templateNotes.length > 0) { - items.push({ title: "----" }); - - for (const templateNote of templateNotes) { - items.push({ - title: templateNote.title, - uiIcon: templateNote.getIcon(), - command: command, - type: templateNote.type, - templateNoteId: templateNote.noteId - }); - } + if (templateNotes.length === 0) { + return []; } + const items: MenuItem[] = [ + SEPARATOR + ]; + for (const templateNote of templateNotes) { + items.push({ + title: templateNote.title, + uiIcon: templateNote.getIcon(), + command: command, + type: templateNote.type, + templateNoteId: templateNote.noteId + }); + } return items; } From 47eaee8b70480830c7f230b24ab17bbfdea88659 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 18:45:06 +0300 Subject: [PATCH 14/16] feat(builtin_templates): add description field for text snippets --- apps/server/src/services/hidden_subtree_templates.ts | 5 +++++ packages/commons/src/lib/hidden_subtree.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/apps/server/src/services/hidden_subtree_templates.ts index 8e4a628a4..c9e36c2b8 100644 --- a/apps/server/src/services/hidden_subtree_templates.ts +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -18,6 +18,11 @@ export default function buildHiddenSubtreeTemplates() { { name: "textSnippet", type: "label" + }, + { + name: "label:textSnippetDescription", + type: "label", + value: "promoted,alias=Description,single,text" } ] } diff --git a/packages/commons/src/lib/hidden_subtree.ts b/packages/commons/src/lib/hidden_subtree.ts index 54f386668..9ceaf8a17 100644 --- a/packages/commons/src/lib/hidden_subtree.ts +++ b/packages/commons/src/lib/hidden_subtree.ts @@ -12,7 +12,7 @@ enum Command { } export interface HiddenSubtreeAttribute { - type: AttributeType; + type: "label" | "relation"; name: string; isInheritable?: boolean; value?: string; From dcccb5ad30c8054659852a152c4a8c96a57c3109 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 19:07:05 +0300 Subject: [PATCH 15/16] feat(builtin_templates): add icon for text snippets --- apps/server/src/services/hidden_subtree_templates.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/services/hidden_subtree_templates.ts b/apps/server/src/services/hidden_subtree_templates.ts index c9e36c2b8..e4c5c62ae 100644 --- a/apps/server/src/services/hidden_subtree_templates.ts +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -10,6 +10,7 @@ export default function buildHiddenSubtreeTemplates() { id: "_template_text_snippet", type: "text", title: "Text Snippet", + icon: "bx bx-align-left", attributes: [ { name: "template", From 374309a40c0376050441bd4e2a384176cad4edb7 Mon Sep 17 00:00:00 2001 From: Elian Doran Date: Tue, 17 Jun 2025 19:16:32 +0300 Subject: [PATCH 16/16] fix(templates): description displayed on separate lines --- apps/client/src/stylesheets/style.css | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 38514e8ad..9b83c37fe 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -1280,16 +1280,19 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu { padding: 0.5em 1em !important; } -.ck.ck-slash-command-button__text-part { +.ck.ck-slash-command-button__text-part, +.ck.ck-template-form__text-part { margin-left: 0.5em; line-height: 1.2em !important; } -.ck.ck-slash-command-button__text-part > span { +.ck.ck-slash-command-button__text-part > span, +.ck.ck-template-form__text-part > span { line-height: inherit !important; } -.ck.ck-slash-command-button__text-part .ck.ck-slash-command-button__description { +.ck.ck-slash-command-button__text-part .ck.ck-slash-command-button__description, +.ck.ck-template-form__text-part .ck-template-form__description { display: block; opacity: 0.8; }