Text snippets (#2344)

This commit is contained in:
Elian Doran 2025-06-17 19:29:53 +03:00 committed by GitHub
commit 60dbf9dd67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 241 additions and 26 deletions

View File

@ -281,6 +281,7 @@ export type CommandMappings = {
buildIcon(name: string): NativeImage; buildIcon(name: string): NativeImage;
}; };
refreshTouchBar: CommandData; refreshTouchBar: CommandData;
reloadTextEditor: CommandData;
}; };
type EventMappings = { type EventMappings = {

View File

@ -245,6 +245,10 @@ class FrocaImpl implements Froca {
} }
async getNotes(noteIds: string[] | JQuery<string>, silentNotFoundError = false): Promise<FNote[]> { async getNotes(noteIds: string[] | JQuery<string>, silentNotFoundError = false): Promise<FNote[]> {
if (noteIds.length === 0) {
return [];
}
noteIds = Array.from(new Set(noteIds)); // make unique noteIds = Array.from(new Set(noteIds)); // make unique
const missingNoteIds = noteIds.filter((noteId) => !this.notes[noteId]); const missingNoteIds = noteIds.filter((noteId) => !this.notes[noteId]);

View File

@ -4,6 +4,8 @@ import { t } from "./i18n.js";
import type { MenuItem } from "../menus/context_menu.js"; import type { MenuItem } from "../menus/context_menu.js";
import type { TreeCommandNames } from "../menus/tree_context_menu.js"; import type { TreeCommandNames } from "../menus/tree_context_menu.js";
const SEPARATOR = { title: "----" };
async function getNoteTypeItems(command?: TreeCommandNames) { async function getNoteTypeItems(command?: TreeCommandNames) {
const items: MenuItem<TreeCommandNames>[] = [ const items: MenuItem<TreeCommandNames>[] = [
{ title: t("note_types.text"), command, type: "text", uiIcon: "bx bx-note" }, { 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.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.mind-map"), command, type: "mindMap", uiIcon: "bx bx-sitemap" },
{ title: t("note_types.geo-map"), command, type: "geoMap", uiIcon: "bx bx-map-alt" }, { 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 templateNoteIds = await server.get<string[]>("search-templates");
const templateNotes = await froca.getNotes(templateNoteIds); const templateNotes = await froca.getNotes(templateNoteIds);
if (templateNotes.length === 0) {
if (templateNotes.length > 0) { return [];
items.push({ title: "----" });
for (const templateNote of templateNotes) {
items.push({
title: templateNote.title,
uiIcon: templateNote.getIcon(),
command: command,
type: templateNote.type,
templateNoteId: templateNote.noteId
});
}
} }
const items: MenuItem<TreeCommandNames>[] = [
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<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; return items;
} }

View File

@ -1280,16 +1280,19 @@ body:not(.mobile) #launcher-pane.horizontal .dropdown-submenu > .dropdown-menu {
padding: 0.5em 1em !important; 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; margin-left: 0.5em;
line-height: 1.2em !important; 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; 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; display: block;
opacity: 0.8; opacity: 0.8;
} }

View File

@ -8,4 +8,9 @@ declare module "*?url" {
export default path; export default path;
} }
declare module "*?raw" {
var content: string;
export default content;
}
declare module "boxicons/css/boxicons.min.css" { } declare module "boxicons/css/boxicons.min.css" { }

View File

@ -7,13 +7,14 @@ import { ensureMimeTypesForHighlighting, isSyntaxHighlightEnabled } from "../../
import utils from "../../../services/utils.js"; import utils from "../../../services/utils.js";
import emojiDefinitionsUrl from "@triliumnext/ckeditor5/emoji_definitions/en.json?url"; import emojiDefinitionsUrl from "@triliumnext/ckeditor5/emoji_definitions/en.json?url";
import { copyTextWithToast } from "../../../services/clipboard_ext.js"; import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import getTemplates from "./snippets.js";
const TEXT_FORMATTING_GROUP = { const TEXT_FORMATTING_GROUP = {
label: "Text formatting", label: "Text formatting",
icon: "text" icon: "text"
}; };
export function buildConfig(): EditorConfig { export async function buildConfig(): Promise<EditorConfig> {
return { return {
image: { image: {
styles: { styles: {
@ -126,6 +127,9 @@ export function buildConfig(): EditorConfig {
dropdownLimit: Number.MAX_SAFE_INTEGER, dropdownLimit: Number.MAX_SAFE_INTEGER,
extraCommands: buildExtraCommands() extraCommands: buildExtraCommands()
}, },
template: {
definitions: await getTemplates()
},
// This value must be kept in sync with the language defined in webpack.config.js. // This value must be kept in sync with the language defined in webpack.config.js.
language: "en" language: "en"
}; };
@ -206,6 +210,7 @@ export function buildClassicToolbar(multilineToolbar: boolean) {
"outdent", "outdent",
"indent", "indent",
"|", "|",
"insertTemplate",
"markdownImport", "markdownImport",
"cuttonote", "cuttonote",
"findAndReplace" "findAndReplace"
@ -262,6 +267,7 @@ export function buildFloatingToolbar() {
"outdent", "outdent",
"indent", "indent",
"|", "|",
"insertTemplate",
"imageUpload", "imageUpload",
"markdownImport", "markdownImport",
"specialCharacters", "specialCharacters",

View 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;
}

View File

@ -18,6 +18,7 @@ import { getMermaidConfig } from "../../services/mermaid.js";
import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig } from "@triliumnext/ckeditor5"; import { PopupEditor, ClassicEditor, EditorWatchdog, type CKTextEditor, type MentionFeed, type WatchdogConfig } from "@triliumnext/ckeditor5";
import "@triliumnext/ckeditor5/index.css"; import "@triliumnext/ckeditor5/index.css";
import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons"; import { normalizeMimeTypeForCKEditor } from "@triliumnext/commons";
import { updateTemplateCache } from "./ckeditor/snippets.js";
const mentionSetup: MentionFeed[] = [ const mentionSetup: MentionFeed[] = [
{ {
@ -193,7 +194,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
const finalConfig = { const finalConfig = {
...editorConfig, ...editorConfig,
...buildConfig(), ...(await buildConfig()),
...buildToolbarConfig(isClassicEditor), ...buildToolbarConfig(isClassicEditor),
htmlSupport: { htmlSupport: {
allow: JSON.parse(options.get("allowedHtmlTags")), allow: JSON.parse(options.get("allowedHtmlTags")),
@ -326,7 +327,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
const data = blob?.content || ""; const data = blob?.content || "";
const newContentLanguage = this.note?.getLabelValue("language"); const newContentLanguage = this.note?.getLabelValue("language");
if (this.contentLanguage !== newContentLanguage) { if (this.contentLanguage !== newContentLanguage) {
await this.reinitialize(data); await this.reinitializeWithData(data);
} else { } else {
this.watchdog.editor?.setData(data); this.watchdog.editor?.setData(data);
} }
@ -562,7 +563,7 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
this.refreshIncludedNote(this.$editor, noteId); this.refreshIncludedNote(this.$editor, noteId);
} }
async reinitialize(data: string) { async reinitializeWithData(data: string) {
if (!this.watchdog) { if (!this.watchdog) {
return; return;
} }
@ -572,9 +573,25 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
this.watchdog.editor?.setData(data); this.watchdog.editor?.setData(data);
} }
async onLanguageChanged() { async reinitialize() {
const data = this.watchdog.editor?.getData(); 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">) { buildTouchBarCommand(data: CommandListenerData<"buildTouchBar">) {

View File

@ -8,6 +8,7 @@ import migrationService from "./migration.js";
import { t } from "i18next"; import { t } from "i18next";
import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js"; import { cleanUpHelp, getHelpHiddenSubtreeData } from "./in_app_help.js";
import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js"; import buildLaunchBarConfig from "./hidden_subtree_launcherbar.js";
import buildHiddenSubtreeTemplates from "./hidden_subtree_templates.js";
const LBTPL_ROOT = "_lbTplRoot"; const LBTPL_ROOT = "_lbTplRoot";
const LBTPL_BASE = "_lbTplBase"; const LBTPL_BASE = "_lbTplBase";
@ -257,7 +258,8 @@ function buildHiddenSubtreeDefinition(helpSubtree: HiddenSubtreeItem[]): HiddenS
icon: "bx-help-circle", icon: "bx-help-circle",
children: helpSubtree, children: helpSubtree,
isExpanded: true isExpanded: true
} },
buildHiddenSubtreeTemplates()
] ]
}; };
} }

View 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;
}

View File

@ -4,6 +4,7 @@ import { COMMON_PLUGINS, CORE_PLUGINS, POPUP_EDITOR_PLUGINS } from "./plugins";
import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5"; import { BalloonEditor, DecoupledEditor, FindAndReplaceEditing, FindCommand } from "ckeditor5";
export { EditorWatchdog } from "ckeditor5"; export { EditorWatchdog } from "ckeditor5";
export type { EditorConfig, MentionFeed, MentionFeedObjectItem, Node, Position, Element, WatchdogConfig } 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"; export { default as buildExtraCommands } from "./extra_slash_commands.js";
// Import with sideffects to ensure that type augmentations are present. // Import with sideffects to ensure that type augmentations are present.

View File

@ -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 { 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 type { Plugin } from "ckeditor5";
import CutToNotePlugin from "./plugins/cuttonote.js"; import CutToNotePlugin from "./plugins/cuttonote.js";
import UploadimagePlugin from "./plugins/uploadimage.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. * Plugins that require a premium CKEditor license key to work.
*/ */
export const PREMIUM_PLUGINS: typeof Plugin[] = [ export const PREMIUM_PLUGINS: typeof Plugin[] = [
SlashCommand SlashCommand,
Template
]; ];
/** /**

View File

@ -12,7 +12,7 @@ enum Command {
} }
export interface HiddenSubtreeAttribute { export interface HiddenSubtreeAttribute {
type: AttributeType; type: "label" | "relation";
name: string; name: string;
isInheritable?: boolean; isInheritable?: boolean;
value?: string; value?: string;