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/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 4de0bd8bf..e9905063c 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,25 +20,59 @@ 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 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; +} + +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; } 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; } 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/config.ts b/apps/client/src/widgets/type_widgets/ckeditor/config.ts index 39b3261b3..03bd5fc07 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" }; @@ -206,6 +210,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) { "outdent", "indent", "|", + "insertTemplate", "markdownImport", "cuttonote", "findAndReplace" @@ -262,6 +267,7 @@ export function buildFloatingToolbar() { "outdent", "indent", "|", + "insertTemplate", "imageUpload", "markdownImport", "specialCharacters", 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..b2d99cfd6 --- /dev/null +++ b/apps/client/src/widgets/type_widgets/ckeditor/snippets.ts @@ -0,0 +1,105 @@ +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"; +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; + description?: string; + content?: string; +} + +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. + * + * @returns the list of templates. + */ +export default async function getTemplates() { + // Build the definitions and populate the cache. + const snippets = await search.searchForNotes("#textSnippet"); + const definitions: TemplateDefinition[] = []; + for (const snippet of snippets) { + const { description } = await invalidateCacheFor(snippet); + + definitions.push({ + title: snippet.title, + data: () => templateCache.get(snippet.noteId)?.content ?? "", + 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"); +} + +async function handleContentUpdate(affectedNoteIds: string[]) { + const updatedNoteIds = new Set(affectedNoteIds); + const templateNoteIds = new Set(templateCache.keys()); + const affectedTemplateNoteIds = templateNoteIds.intersection(updatedNoteIds); + + await froca.getNotes(affectedNoteIds); + + let fullReloadNeeded = false; + for (const affectedTemplateNoteId of affectedTemplateNoteIds) { + 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) { + fullReloadNeeded = true; + break; + } + + 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; + } + } + + if (fullReloadNeeded) { + handleFullReload(); + } +} + +export function updateTemplateCache(loadResults: LoadResults): boolean { + const affectedNoteIds = loadResults.getNoteIds(); + + // React to creation or deletion of text snippets. + if (loadResults.getAttributeRows().find((attr) => + attr.type === "label" && + (attr.name === "textSnippet" || attr.name === "textSnippetDescription"))) { + handleFullReload(); + } else if (affectedNoteIds.length > 0) { + // Update content and titles if one of the template notes were updated. + debouncedHandleContentUpdate(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 329e59824..28094f2a2 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[] = [ { @@ -193,7 +194,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { const finalConfig = { ...editorConfig, - ...buildConfig(), + ...(await buildConfig()), ...buildToolbarConfig(isClassicEditor), htmlSupport: { allow: JSON.parse(options.get("allowedHtmlTags")), @@ -326,7 +327,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 +563,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 +573,25 @@ 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 reloadTextEditorEvent() { + await this.reinitialize(); + } + + async onLanguageChanged() { + await this.reinitialize(); + } + + async entitiesReloadedEvent(e: EventData<"entitiesReloaded">) { + await super.entitiesReloadedEvent(e); + + if (updateTemplateCache(e.loadResults)) { + await this.reinitialize(); + } } buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) { 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..e4c5c62ae --- /dev/null +++ b/apps/server/src/services/hidden_subtree_templates.ts @@ -0,0 +1,34 @@ +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", + icon: "bx bx-align-left", + attributes: [ + { + name: "template", + type: "label" + }, + { + name: "textSnippet", + type: "label" + }, + { + name: "label:textSnippetDescription", + type: "label", + value: "promoted,alias=Description,single,text" + } + ] + } + ] + }; + + return templates; +} 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 ]; /** 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;