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

View File

@@ -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<void>;
}
export default function AddLinkDialog() {
const [ textTypeWidget, setTextTypeWidget ] = useState<TextTypeWidget>();
const initialText = useRef<string>();
const [ opts, setOpts ] = useState<AddLinkOpts>();
const [ linkTitle, setLinkTitle ] = useState("");
const hasSelection = textTypeWidget?.hasSelection();
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
const [ linkType, setLinkType ] = useState<LinkType>();
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(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() {
/>
</FormGroup>
{!hasSelection && (
{!opts?.hasSelection && (
<div className="add-link-title-settings">
{(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 { 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"> {
content: string | undefined;
@@ -13,13 +23,69 @@ interface CKEditorWithWatchdogProps extends Pick<HTMLProps<HTMLDivElement>, "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<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 watchdogRef = useRef<EditorWatchdog>(null);
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(() => {
const container = containerRef.current;
if (!container) return;

View File

@@ -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<HTMLDivElement>(null);
const [ content, setContent ] = useState<string>();
const watchdogRef = useRef<EditorWatchdog>(null);
const editorApiRef = useRef<CKEditorApi>(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.

View File

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

View File

@@ -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;