chore(react/type_widgets): port text link insertion mechanism

This commit is contained in:
Elian Doran
2025-09-25 12:07:06 +03:00
parent 232fe4e63a
commit cc6ac7d1da
6 changed files with 102 additions and 81 deletions

View File

@@ -33,6 +33,7 @@ import { ColumnComponent } from "tabulator-tables";
import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx"; import { ChooseNoteTypeCallback } from "../widgets/dialogs/note_type_chooser.jsx";
import type RootContainer from "../widgets/containers/root_container.js"; import type RootContainer from "../widgets/containers/root_container.js";
import { SqlExecuteResults } from "@triliumnext/commons"; import { SqlExecuteResults } from "@triliumnext/commons";
import { AddLinkOpts } from "../widgets/dialogs/add_link.jsx";
interface Layout { interface Layout {
getRootWidget: (appContext: AppContext) => RootContainer; getRootWidget: (appContext: AppContext) => RootContainer;
@@ -223,7 +224,7 @@ export type CommandMappings = {
showProtectedSessionPasswordDialog: CommandData; showProtectedSessionPasswordDialog: CommandData;
showUploadAttachmentsDialog: CommandData & { noteId: string }; showUploadAttachmentsDialog: CommandData & { noteId: string };
showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget }; showIncludeNoteDialog: CommandData & { textTypeWidget: EditableTextTypeWidget };
showAddLinkDialog: CommandData & { textTypeWidget: EditableTextTypeWidget, text: string }; showAddLinkDialog: CommandData & AddLinkOpts;
closeProtectedSessionPasswordDialog: CommandData; closeProtectedSessionPasswordDialog: CommandData;
copyImageReferenceToClipboard: CommandData; copyImageReferenceToClipboard: CommandData;
copyImageToClipboard: CommandData; copyImageToClipboard: CommandData;

View File

@@ -6,7 +6,6 @@ import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks"; import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree"; import tree from "../../services/tree";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete"; 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 { logError } from "../../services/ws";
import FormGroup from "../react/FormGroup.js"; import FormGroup from "../react/FormGroup.js";
import { refToJQuerySelector } from "../react/react_utils"; import { refToJQuerySelector } from "../react/react_utils";
@@ -14,28 +13,31 @@ import { useTriliumEvent } from "../react/hooks";
type LinkType = "reference-link" | "external-link" | "hyper-link"; type LinkType = "reference-link" | "external-link" | "hyper-link";
export interface AddLinkOpts {
text: string;
hasSelection: boolean;
addLink(notePath: string, linkTitle: string | null, externalLink?: boolean): Promise<void>;
}
export default function AddLinkDialog() { export default function AddLinkDialog() {
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>(); const [ opts, setOpts ] = useState<AddLinkOpts>();
const initialText = useRef<string>();
const [ linkTitle, setLinkTitle ] = useState(""); const [ linkTitle, setLinkTitle ] = useState("");
const hasSelection = textTypeWidget?.hasSelection(); const [ linkType, setLinkType ] = useState<LinkType>();
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null); const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
const [ shown, setShown ] = useState(false); const [ shown, setShown ] = useState(false);
useTriliumEvent("showAddLinkDialog", ( { textTypeWidget, text }) => { useTriliumEvent("showAddLinkDialog", opts => {
setTextTypeWidget(textTypeWidget); setOpts(opts);
initialText.current = text;
setShown(true); setShown(true);
}); });
useEffect(() => { useEffect(() => {
if (hasSelection) { if (opts?.hasSelection) {
setLinkType("hyper-link"); setLinkType("hyper-link");
} else { } else {
setLinkType("reference-link"); setLinkType("reference-link");
} }
}, [ hasSelection ]) }, [ opts ])
async function setDefaultLinkTitle(noteId: string) { async function setDefaultLinkTitle(noteId: string) {
const noteTitle = await tree.getNoteTitle(noteId); const noteTitle = await tree.getNoteTitle(noteId);
@@ -70,10 +72,10 @@ export default function AddLinkDialog() {
function onShown() { function onShown() {
const $autocompleteEl = refToJQuerySelector(autocompleteRef); const $autocompleteEl = refToJQuerySelector(autocompleteRef);
if (!initialText.current) { if (!opts?.text) {
note_autocomplete.showRecentNotes($autocompleteEl); note_autocomplete.showRecentNotes($autocompleteEl);
} else { } else {
note_autocomplete.setText($autocompleteEl, initialText.current); note_autocomplete.setText($autocompleteEl, opts.text);
} }
// to be able to quickly remove entered text // to be able to quickly remove entered text
@@ -86,11 +88,11 @@ export default function AddLinkDialog() {
if (suggestion?.notePath) { if (suggestion?.notePath) {
// Handle note link // Handle note link
setShown(false); setShown(false);
textTypeWidget?.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); opts?.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestion?.externalLink) { } else if (suggestion?.externalLink) {
// Handle external link // Handle external link
setShown(false); setShown(false);
textTypeWidget?.addLink(suggestion.externalLink, linkTitle, true); opts?.addLink(suggestion.externalLink, linkTitle, true);
} else { } else {
logError("No link to add."); logError("No link to add.");
} }
@@ -125,7 +127,7 @@ export default function AddLinkDialog() {
/> />
</FormGroup> </FormGroup>
{!hasSelection && ( {!opts?.hasSelection && (
<div className="add-link-title-settings"> <div className="add-link-title-settings">
{(linkType !== "external-link") && ( {(linkType !== "external-link") && (
<> <>

View File

@@ -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 { PopupEditor, ClassicEditor, EditorWatchdog, type WatchdogConfig, CKTextEditor } from "@triliumnext/ckeditor5";
import { buildConfig, BuildEditorOptions } from "./config"; 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<HTMLProps<HTMLDivElement>, "className" | "tabIndex"> { interface CKEditorWithWatchdogProps extends Pick<HTMLProps<HTMLDivElement>, "className" | "tabIndex"> {
content: string | undefined; content: string | undefined;
@@ -13,13 +23,69 @@ interface CKEditorWithWatchdogProps extends Pick<HTMLProps<HTMLDivElement>, "cla
onChange: () => void; 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). */ /** 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; onEditorInitialized?: (editor: CKTextEditor) => void;
editorApi: RefObject<CKEditorApi>;
} }
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<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const watchdogRef = useRef<EditorWatchdog>(null); const watchdogRef = useRef<EditorWatchdog>(null);
const [ editor, setEditor ] = useState<CKTextEditor>(); const [ editor, setEditor ] = useState<CKTextEditor>();
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<HTMLElement>, href: string | null = null) {
await link.loadReferenceLinkTitle($el, href);
}
})
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;

View File

@@ -4,11 +4,10 @@ import toast from "../../../services/toast";
import utils, { deferred, isMobile } from "../../../services/utils"; import utils, { deferred, isMobile } from "../../../services/utils";
import { useEditorSpacedUpdate, useKeyboardShortcuts, useNoteLabel, useTriliumEvent, useTriliumOption } from "../../react/hooks"; import { useEditorSpacedUpdate, useKeyboardShortcuts, useNoteLabel, useTriliumEvent, useTriliumOption } from "../../react/hooks";
import { TypeWidgetProps } from "../type_widget"; import { TypeWidgetProps } from "../type_widget";
import CKEditorWithWatchdog from "./CKEditorWithWatchdog"; import CKEditorWithWatchdog, { CKEditorApi } from "./CKEditorWithWatchdog";
import "./EditableText.css"; import "./EditableText.css";
import { CKTextEditor, ClassicEditor, EditorWatchdog } from "@triliumnext/ckeditor5"; import { CKTextEditor, ClassicEditor, EditorWatchdog } from "@triliumnext/ckeditor5";
import Component from "../../../components/component"; import Component from "../../../components/component";
import { RefObject } from "preact";
import options from "../../../services/options"; import options from "../../../services/options";
/** /**
@@ -21,6 +20,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [ content, setContent ] = useState<string>(); const [ content, setContent ] = useState<string>();
const watchdogRef = useRef<EditorWatchdog>(null); const watchdogRef = useRef<EditorWatchdog>(null);
const editorApiRef = useRef<CKEditorApi>(null);
const [ language ] = useNoteLabel(note, "language"); const [ language ] = useNoteLabel(note, "language");
const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType"); const [ textNoteEditorType ] = useTriliumOption("textNoteEditorType");
const isClassicEditor = isMobile() || textNoteEditorType === "ckeditor-classic"; const isClassicEditor = isMobile() || textNoteEditorType === "ckeditor-classic";
@@ -66,6 +66,18 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
editor?.editing.view.focus(); 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() { async function waitForEditor() {
await initialized.current; await initialized.current;
const editor = watchdogRef.current?.editor; const editor = watchdogRef.current?.editor;
@@ -104,6 +116,7 @@ export default function EditableText({ note, parentComponent, ntxId, noteContext
content={content} content={content}
contentLanguage={language} contentLanguage={language}
isClassicEditor={isClassicEditor} isClassicEditor={isClassicEditor}
editorApi={editorApiRef}
watchdogRef={watchdogRef} watchdogRef={watchdogRef}
watchdogConfig={{ 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. // 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.

View File

@@ -13,11 +13,6 @@ export default class AbstractTextTypeWidget extends TypeWidget {
this.refreshCodeBlockOptions(); this.refreshCodeBlockOptions();
} }
async loadReferenceLinkTitle($el: JQuery<HTMLElement>, href: string | null = null) {
await linkService.loadReferenceLinkTitle($el, href);
}
refreshCodeBlockOptions() { refreshCodeBlockOptions() {
const wordWrap = options.is("codeBlockWordWrap"); const wordWrap = options.is("codeBlockWordWrap");
this.$widget.toggleClass("word-wrap", wordWrap); this.$widget.toggleClass("word-wrap", wordWrap);

View File

@@ -55,39 +55,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
return this.watchdog?.editor; 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">) { async executeWithTextEditorEvent({ callback, resolve, ntxId }: EventData<"executeWithTextEditor">) {
@@ -108,29 +75,6 @@ export default class EditableTextTypeWidget extends AbstractTextTypeWidget {
resolve(this.watchdog.editor as CKTextEditor); 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() { async followLinkUnderCursorCommand() {
await this.initialized; await this.initialized;