mirror of
				https://github.com/zadam/trilium.git
				synced 2025-10-31 18:36:30 +01:00 
			
		
		
		
	Text snippets (#2344)
This commit is contained in:
		| @@ -281,6 +281,7 @@ export type CommandMappings = { | ||||
|         buildIcon(name: string): NativeImage; | ||||
|     }; | ||||
|     refreshTouchBar: CommandData; | ||||
|     reloadTextEditor: CommandData; | ||||
| }; | ||||
|  | ||||
| type EventMappings = { | ||||
|   | ||||
| @@ -245,6 +245,10 @@ class FrocaImpl implements Froca { | ||||
|     } | ||||
|  | ||||
|     async getNotes(noteIds: string[] | JQuery<string>, silentNotFoundError = false): Promise<FNote[]> { | ||||
|         if (noteIds.length === 0) { | ||||
|             return []; | ||||
|         } | ||||
|  | ||||
|         noteIds = Array.from(new Set(noteIds)); // make unique | ||||
|         const missingNoteIds = noteIds.filter((noteId) => !this.notes[noteId]); | ||||
|  | ||||
|   | ||||
| @@ -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<TreeCommandNames>[] = [ | ||||
|         { title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" }, | ||||
| @@ -18,14 +20,23 @@ 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<string[]>("search-templates"); | ||||
|     const templateNotes = await froca.getNotes(templateNoteIds); | ||||
|     if (templateNotes.length === 0) { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     if (templateNotes.length > 0) { | ||||
|         items.push({ title: "----" }); | ||||
|  | ||||
|     const items: MenuItem<TreeCommandNames>[] = [ | ||||
|         SEPARATOR | ||||
|     ]; | ||||
|     for (const templateNote of templateNotes) { | ||||
|         items.push({ | ||||
|             title: templateNote.title, | ||||
| @@ -35,8 +46,33 @@ async function getNoteTypeItems(command?: TreeCommandNames) { | ||||
|             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<TreeCommandNames>[] = [ | ||||
|         SEPARATOR | ||||
|     ]; | ||||
|     for (const templateNote of childNotes) { | ||||
|         items.push({ | ||||
|             title: templateNote.title, | ||||
|             uiIcon: templateNote.getIcon(), | ||||
|             command: command, | ||||
|             type: templateNote.type, | ||||
|             templateNoteId: templateNote.noteId | ||||
|         }); | ||||
|     } | ||||
|     return items; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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; | ||||
| } | ||||
|   | ||||
							
								
								
									
										5
									
								
								apps/client/src/types-assets.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								apps/client/src/types-assets.d.ts
									
									
									
									
										vendored
									
									
								
							| @@ -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" { } | ||||
|   | ||||
| @@ -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<EditorConfig> { | ||||
|     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", | ||||
|   | ||||
							
								
								
									
										105
									
								
								apps/client/src/widgets/type_widgets/ckeditor/snippets.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								apps/client/src/widgets/type_widgets/ckeditor/snippets.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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<string, TemplateData> = 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; | ||||
| } | ||||
| @@ -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">) { | ||||
|   | ||||
| @@ -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() | ||||
|         ] | ||||
|     }; | ||||
| } | ||||
|   | ||||
							
								
								
									
										34
									
								
								apps/server/src/services/hidden_subtree_templates.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								apps/server/src/services/hidden_subtree_templates.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -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; | ||||
| } | ||||
| @@ -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. | ||||
|   | ||||
| @@ -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 | ||||
| ]; | ||||
|  | ||||
| /** | ||||
|   | ||||
| @@ -12,7 +12,7 @@ enum Command { | ||||
| } | ||||
|  | ||||
| export interface HiddenSubtreeAttribute { | ||||
|     type: AttributeType; | ||||
|     type: "label" | "relation"; | ||||
|     name: string; | ||||
|     isInheritable?: boolean; | ||||
|     value?: string; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user