diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 49fab20c0..88222a497 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -33,6 +33,7 @@ import { ColumnComponent } from "tabulator-tables"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import type RootContainer from "../widgets/containers/root_container.js"; import { SqlExecuteResults } from "@triliumnext/commons"; +import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx"; interface Layout { getRootWidget: (appContext: AppContext) => RootContainer; @@ -223,7 +224,7 @@ export type CommandMappings = { showProtectedSessionPasswordDialog: CommandData; showUploadAttachmentsDialog: CommandData & { noteId: string }; showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; - showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string }; + showAddLinkDialog: CommandData & AddLinkOpts; closeProtectedSessionPasswordDialog: CommandData; copyImageReferenceToClipboard: CommandData; copyImageToClipboard: CommandData; diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index 6128dca94..287a19379 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -6,7 +6,6 @@ import NoteAutocomplete from "../react/NoteAutocomplete"; import { useRef, useState, useEffect } from "preact/hooks"; import tree from "../../services/tree"; import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; -import { default as TextTypeWidget } from "../type_widgets_old/editable_text.js"; import { logError } from "../../services/ws"; import FormGroup from "../react/FormGroup.js"; import { refToJQuerySelector } from "../react/react_utils"; @@ -14,28 +13,31 @@ import { useTriliumEvent } from "../react/hooks"; type LinkType = "reference-link" | "external-link" | "hyper-link"; +export interface AddLinkOpts { + text: string; + hasSelection: boolean; + addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): Promise; +} + export default function AddLinkDialog() { - const [ textTypeWidget, setTextTypeWidget ] = useState(); - const initialText = useRef(); + const [ opts, setOpts ] = useState(); const [ linkTitle, setLinkTitle ] = useState(""); - const hasSelection = textTypeWidget?.hasSelection(); - const [ linkType, setLinkType ] = useState(hasSelection ? "hyper-link" : "reference-link"); + const [ linkType, setLinkType ] = useState(); const [ suggestion, setSuggestion ] = useState(null); const [ shown, setShown ] = useState(false); - useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => { - setTextTypeWidget(textTypeWidget); - initialText.current = text; + useTriliumEvent("showAddLinkDialog", opts => { + setOpts(opts); setShown(true); }); useEffect(() => { - if (hasSelection) { + if (opts?.hasSelection) { setLinkType("hyper-link"); } else { setLinkType("reference-link"); } - }, [ hasSelection ]) + }, [ opts ]) async function setDefaultLinkTitle(noteId: string) { const noteTitle = await tree.getNoteTitle(noteId); @@ -70,10 +72,10 @@ export default function AddLinkDialog() { function onShown() { const $autocompleteEl = refToJQuerySelector(autocompleteRef); - if (!initialText.current) { + if (!opts?.text) { note_autocomplete.showRecentNotes($autocompleteEl); } else { - note_autocomplete.setText($autocompleteEl, initialText.current); + note_autocomplete.setText($autocompleteEl, opts.text); } // to be able to quickly remove entered text @@ -86,11 +88,11 @@ export default function AddLinkDialog() { if (suggestion?.notePath) { // Handle note link setShown(false); - textTypeWidget?.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); + opts?.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); } else if (suggestion?.externalLink) { // Handle external link setShown(false); - textTypeWidget?.addLink(suggestion.externalLink, linkTitle, true); + opts?.addLink(suggestion.externalLink, linkTitle, true); } else { logError("No link to add."); } @@ -125,7 +127,7 @@ export default function AddLinkDialog() { /> - {!hasSelection && ( + {!opts?.hasSelection && (
{(linkType !== "external-link") && ( <> diff --git a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx index 5d7df279d..45bf7bd0d 100644 --- a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx +++ b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx @@ -1,6 +1,16 @@ -import { HTMLProps, RefObject, useEffect, useRef, useState } from "preact/compat"; +import { HTMLProps, RefObject, useEffect, useImperativeHandle, useRef, useState } from "preact/compat"; import { PopupEditor, ClassicEditor, EditorWatchdog, type WatchdogConfig, CKTextEditor } from "@triliumnext/ckeditor5"; import { buildConfig, BuildEditorOptions } from "./config"; +import { useLegacyImperativeHandlers } from "../../react/hooks"; +import link from "../../../services/link"; + +export interface CKEditorApi { + /** returns true if user selected some text, false if there's no selection */ + hasSelection(): boolean; + getSelectedText(): string; + addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): void; + addLinkToEditor(linkHref: string, linkTitle: string): void; +} interface CKEditorWithWatchdogProps extends Pick, "className" | "tabIndex"> { content: string | undefined; @@ -13,13 +23,69 @@ interface CKEditorWithWatchdogProps extends Pick, "cla onChange: () => void; /** Called upon whenever a new CKEditor instance is initialized, whether it's the first initialization, after a crash or after a config change that requires it (e.g. content language). */ onEditorInitialized?: (editor: CKTextEditor) => void; + editorApi: RefObject; } -export default function CKEditorWithWatchdog({ content, contentLanguage, className, tabIndex, isClassicEditor, watchdogRef: externalWatchdogRef, watchdogConfig, onNotificationWarning, onWatchdogStateChange, onChange, onEditorInitialized }: CKEditorWithWatchdogProps) { +export default function CKEditorWithWatchdog({ content, contentLanguage, className, tabIndex, isClassicEditor, watchdogRef: externalWatchdogRef, watchdogConfig, onNotificationWarning, onWatchdogStateChange, onChange, onEditorInitialized, editorApi }: CKEditorWithWatchdogProps) { const containerRef = useRef(null); const watchdogRef = useRef(null); const [ editor, setEditor ] = useState(); + useImperativeHandle(editorApi, () => ({ + hasSelection() { + const model = watchdogRef.current?.editor?.model; + const selection = model?.document.selection; + + return !selection?.isCollapsed; + }, + getSelectedText() { + const range = watchdogRef.current?.editor?.model.document.selection.getFirstRange(); + let text = ""; + + if (!range) { + return text; + } + + for (const item of range.getItems()) { + if ("data" in item && item.data) { + text += item.data; + } + } + + return text; + }, + addLink(notePath, linkTitle, externalLink) { + const editor = watchdogRef.current?.editor; + if (!editor) return; + + if (linkTitle) { + if (this.hasSelection()) { + editor.execute("link", externalLink ? `${notePath}` : `#${notePath}`); + } else { + this.addLinkToEditor(externalLink ? `${notePath}` : `#${notePath}`, linkTitle); + } + } else { + editor.execute("referenceLink", { href: "#" + notePath }); + } + + editor.editing.view.focus(); + }, + addLinkToEditor(linkHref, linkTitle) { + watchdogRef.current?.editor?.model.change((writer) => { + const insertPosition = watchdogRef.current?.editor?.model.document.selection.getFirstPosition(); + if (insertPosition) { + writer.insertText(linkTitle, { linkHref: linkHref }, insertPosition); + } + }); + }, + })); + + useLegacyImperativeHandlers({ + async loadReferenceLinkTitle($el: JQuery, href: string | null = null) { + await link.loadReferenceLinkTitle($el, href); + } + }) + useEffect(() => { const container = containerRef.current; if (!container) return; diff --git a/apps/client/src/widgets/type_widgets/text/EditableText.tsx b/apps/client/src/widgets/type_widgets/text/EditableText.tsx index b9345d0cb..6be88b2a2 100644 --- a/apps/client/src/widgets/type_widgets/text/EditableText.tsx +++ b/apps/client/src/widgets/type_widgets/text/EditableText.tsx @@ -4,11 +4,10 @@ import toast from "../../../services/toast"; import utils, { deferred, isMobile } from "../../../services/utils"; import { useEditorSpacedUpdate, useKeyboardShortcuts, useNoteLabel, useTriliumEvent, useTriliumOption } from "../../react/hooks"; import { TypeWidgetProps } from "../type_widget"; -import CKEditorWithWatchdog from "./CKEditorWithWatchdog"; +import CKEditorWithWatchdog, { CKEditorApi } from "./CKEditorWithWatchdog"; import "./EditableText.css"; import { CKTextEditor, ClassicEditor, EditorWatchdog } from "@triliumnext/ckeditor5"; import Component from "../../../components/component"; -import { RefObject } from "preact"; import options from "../../../services/options"; /** @@ -21,6 +20,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext const containerRef = useRef(null); const [ content, setContent ] = useState(); const watchdogRef = useRef(null); + const editorApiRef = useRef(null); const [ language ] = useNoteLabel(note, "language"); const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType"); const isClassicEditor = isMobile() || textNoteEditorType === "ckeditor-classic"; @@ -66,6 +66,18 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext editor?.editing.view.focus(); }); + useTriliumEvent("addLinkToText", ({ ntxId: eventNtxId }) => { + if (eventNtxId !== ntxId || !editorApiRef.current) return; + parentComponent?.triggerCommand("showAddLinkDialog", { + text: editorApiRef.current.getSelectedText(), + hasSelection: editorApiRef.current.hasSelection(), + async addLink(notePath, linkTitle, externalLink) { + await waitForEditor(); + return editorApiRef.current?.addLink(notePath, linkTitle, externalLink); + } + }); + }); + async function waitForEditor() { await initialized.current; const editor = watchdogRef.current?.editor; @@ -104,6 +116,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext content={content} contentLanguage={language} isClassicEditor={isClassicEditor} + editorApi={editorApiRef} watchdogRef={watchdogRef} watchdogConfig={{ // An average number of milliseconds between the last editor errors (defaults to 5000). When the period of time between errors is lower than that and the crashNumberLimit is also reached, the watchdog changes its state to crashedPermanently, and it stops restarting the editor. This prevents an infinite restart loop. diff --git a/apps/client/src/widgets/type_widgets_old/abstract_text_type_widget.ts b/apps/client/src/widgets/type_widgets_old/abstract_text_type_widget.ts index 8530bae9a..6208a673b 100644 --- a/apps/client/src/widgets/type_widgets_old/abstract_text_type_widget.ts +++ b/apps/client/src/widgets/type_widgets_old/abstract_text_type_widget.ts @@ -13,11 +13,6 @@ export default class AbstractTextTypeWidget extends TypeWidget { this.refreshCodeBlockOptions(); } - - async loadReferenceLinkTitle($el: JQuery, href: string | null = null) { - await linkService.loadReferenceLinkTitle($el, href); - } - refreshCodeBlockOptions() { const wordWrap = options.is("codeBlockWordWrap"); this.$widget.toggleClass("word-wrap", wordWrap); diff --git a/apps/client/src/widgets/type_widgets_old/editable_text.ts b/apps/client/src/widgets/type_widgets_old/editable_text.ts index 166437eda..f3a66cd60 100644 --- a/apps/client/src/widgets/type_widgets_old/editable_text.ts +++ b/apps/client/src/widgets/type_widgets_old/editable_text.ts @@ -55,39 +55,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { return this.watchdog?.editor; } - async addLinkToEditor(linkHref: string, linkTitle: string) { - await this.initialized; - - this.watchdog.editor?.model.change((writer) => { - const insertPosition = this.watchdog.editor?.model.document.selection.getFirstPosition(); - if (insertPosition) { - writer.insertText(linkTitle, { linkHref: linkHref }, insertPosition); - } - }); - } - - async addLink(notePath: string, linkTitle: string | null, externalLink: boolean = false) { - await this.initialized; - - if (linkTitle) { - if (this.hasSelection()) { - this.watchdog.editor?.execute("link", externalLink ? `${notePath}` : `#${notePath}`); - } else { - await this.addLinkToEditor(externalLink ? `${notePath}` : `#${notePath}`, linkTitle); - } - } else { - this.watchdog.editor?.execute("referenceLink", { href: "#" + notePath }); - } - - this.watchdog.editor?.editing.view.focus(); - } - - // returns true if user selected some text, false if there's no selection - hasSelection() { - const model = this.watchdog.editor?.model; - const selection = model?.document.selection; - - return !selection?.isCollapsed; } async executeWithTextEditorEvent({ callback, resolve, ntxId }: EventData<"executeWithTextEditor">) { @@ -108,29 +75,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget { resolve(this.watchdog.editor as CKTextEditor); } - addLinkToTextCommand() { - const selectedText = this.getSelectedText(); - - this.triggerCommand("showAddLinkDialog", { textTypeWidget: this, text: selectedText }); - } - - getSelectedText() { - const range = this.watchdog.editor?.model.document.selection.getFirstRange(); - let text = ""; - - if (!range) { - return text; - } - - for (const item of range.getItems()) { - if ("data" in item && item.data) { - text += item.data; - } - } - - return text; - } - async followLinkUnderCursorCommand() { await this.initialized;