feat(react): port add_link

This commit is contained in:
Elian Doran 2025-08-04 12:58:42 +03:00
parent a62f12b427
commit e619a6ef7c
No known key found for this signature in database
12 changed files with 207 additions and 199 deletions

View File

@ -34,7 +34,7 @@
"link_title_mirrors": "链接标题跟随笔记标题变化",
"link_title_arbitrary": "链接标题可随意修改",
"link_title": "链接标题",
"button_add_link": "添加链接 <kbd>回车</kbd>"
"button_add_link": "添加链接"
},
"branch_prefix": {
"edit_branch_prefix": "编辑分支前缀",

View File

@ -34,7 +34,7 @@
"link_title_mirrors": "Der Linktitel spiegelt den aktuellen Titel der Notiz wider",
"link_title_arbitrary": "Der Linktitel kann beliebig geändert werden",
"link_title": "Linktitel",
"button_add_link": "Link hinzufügen <kbd>Eingabetaste</kbd>"
"button_add_link": "Link hinzufügen"
},
"branch_prefix": {
"edit_branch_prefix": "Zweigpräfix bearbeiten",

View File

@ -34,7 +34,7 @@
"link_title_mirrors": "link title mirrors the note's current title",
"link_title_arbitrary": "link title can be changed arbitrarily",
"link_title": "Link title",
"button_add_link": "Add link <kbd>enter</kbd>"
"button_add_link": "Add link"
},
"branch_prefix": {
"edit_branch_prefix": "Edit branch prefix",
@ -2007,6 +2007,7 @@
"open_externally": "Open externally"
},
"modal": {
"close": "Close"
"close": "Close",
"help_title": "Display more information about this screen"
}
}

View File

@ -34,7 +34,7 @@
"link_title_mirrors": "el título del enlace replica el título actual de la nota",
"link_title_arbitrary": "el título del enlace se puede cambiar arbitrariamente",
"link_title": "Título del enlace",
"button_add_link": "Agregar enlace <kbd>Enter</kbd>"
"button_add_link": "Agregar enlace"
},
"branch_prefix": {
"edit_branch_prefix": "Editar prefijo de rama",

View File

@ -34,7 +34,7 @@
"link_title_mirrors": "le titre du lien reflète le titre actuel de la note",
"link_title_arbitrary": "le titre du lien peut être modifié arbitrairement",
"link_title": "Titre du lien",
"button_add_link": "Ajouter un lien <kbd>Entrée</kbd>"
"button_add_link": "Ajouter un lien"
},
"branch_prefix": {
"edit_branch_prefix": "Modifier le préfixe de branche",

View File

@ -37,7 +37,7 @@
"link_title_mirrors": "titlul legăturii corespunde titlul curent al notiței",
"note": "Notiță",
"search_note": "căutați notița după nume",
"button_add_link": "Adaugă legătură <kbd>Enter</kbd>"
"button_add_link": "Adaugă legătură"
},
"add_relation": {
"add_relation": "Adaugă relație",

View File

@ -33,7 +33,7 @@
"link_title_mirrors": "鏈接標題跟隨筆記標題變化",
"link_title_arbitrary": "鏈接標題可隨意修改",
"link_title": "鏈接標題",
"button_add_link": "添加鏈接 <kbd>Enter</kbd>"
"button_add_link": "添加鏈接"
},
"branch_prefix": {
"edit_branch_prefix": "編輯分支前綴",

View File

@ -1,188 +0,0 @@
import { t } from "../../services/i18n.js";
import treeService from "../../services/tree.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import BasicWidget from "../basic_widget.js";
import type { Suggestion } from "../../services/note_autocomplete.js";
import type { default as TextTypeWidget } from "../type_widgets/editable_text.js";
import type { EventData } from "../../components/app_context.js";
import { openDialog } from "../../services/dialog.js";
const TPL = /*html*/`
<div class="add-link-dialog modal mx-auto" tabindex="-1" role="dialog">
<div class="modal-dialog modal-lg" style="max-width: 1000px" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title flex-grow-1">${t("add_link.add_link")}</h5>
<button type="button" class="help-button" title="${t("add_link.help_on_links")}" data-help-page="links.html">?</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="${t("add_link.close")}"></button>
</div>
<form class="add-link-form">
<div class="modal-body">
<div class="form-group">
<label for="add-link-note-autocomplete">${t("add_link.note")}</label>
<div class="input-group">
<input class="add-link-note-autocomplete form-control" placeholder="${t("add_link.search_note")}">
</div>
</div>
<div class="add-link-title-settings">
<div class="add-link-title-radios form-check">
<label class="form-check-label">
<input class="form-check-input" type="radio" name="link-type" value="reference-link" checked>
${t("add_link.link_title_mirrors")}
</label>
</div>
<div class="add-link-title-radios form-check">
<label class="form-check-label">
<input class="form-check-input" type="radio" name="link-type" value="hyper-link">
${t("add_link.link_title_arbitrary")}
</label>
</div>
<div class="add-link-title-form-group form-group">
<br/>
<label>
${t("add_link.link_title")}
<input class="link-title form-control" style="width: 100%;">
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary">${t("add_link.button_add_link")}</button>
</div>
</form>
</div>
</div>
</div>`;
export default class AddLinkDialog extends BasicWidget {
private $form!: JQuery<HTMLElement>;
private $autoComplete!: JQuery<HTMLElement>;
private $linkTitle!: JQuery<HTMLElement>;
private $addLinkTitleSettings!: JQuery<HTMLElement>;
private $addLinkTitleRadios!: JQuery<HTMLElement>;
private $addLinkTitleFormGroup!: JQuery<HTMLElement>;
private textTypeWidget: TextTypeWidget | null = null;
doRender() {
this.$widget = $(TPL);
this.$form = this.$widget.find(".add-link-form");
this.$autoComplete = this.$widget.find(".add-link-note-autocomplete");
this.$linkTitle = this.$widget.find(".link-title");
this.$addLinkTitleSettings = this.$widget.find(".add-link-title-settings");
this.$addLinkTitleRadios = this.$widget.find(".add-link-title-radios");
this.$addLinkTitleFormGroup = this.$widget.find(".add-link-title-form-group");
this.$form.on("submit", () => {
if (this.$autoComplete.getSelectedNotePath()) {
this.$widget.modal("hide");
const linkTitle = this.getLinkType() === "reference-link" ? null : this.$linkTitle.val() as string;
this.textTypeWidget?.addLink(this.$autoComplete.getSelectedNotePath()!, linkTitle);
} else if (this.$autoComplete.getSelectedExternalLink()) {
this.$widget.modal("hide");
this.textTypeWidget?.addLink(this.$autoComplete.getSelectedExternalLink()!, this.$linkTitle.val() as string, true);
} else {
logError("No link to add.");
}
return false;
});
}
async showAddLinkDialogEvent({ textTypeWidget, text = "" }: EventData<"showAddLinkDialog">) {
this.textTypeWidget = textTypeWidget;
this.$addLinkTitleSettings.toggle(!this.textTypeWidget.hasSelection());
this.$addLinkTitleSettings.find("input[type=radio]").on("change", () => this.updateTitleSettingsVisibility());
// with selection hyperlink is implied
if (this.textTypeWidget.hasSelection()) {
this.$addLinkTitleSettings.find("input[value='hyper-link']").prop("checked", true);
} else {
this.$addLinkTitleSettings.find("input[value='reference-link']").prop("checked", true);
}
this.updateTitleSettingsVisibility();
await openDialog(this.$widget);
this.$autoComplete.val("");
this.$linkTitle.val("");
const setDefaultLinkTitle = async (noteId: string) => {
const noteTitle = await treeService.getNoteTitle(noteId);
this.$linkTitle.val(noteTitle);
};
noteAutocompleteService.initNoteAutocomplete(this.$autoComplete, {
allowExternalLinks: true,
allowCreatingNotes: true
});
this.$autoComplete.on("autocomplete:noteselected", (event: JQuery.Event, suggestion: Suggestion) => {
if (!suggestion.notePath) {
return false;
}
this.updateTitleSettingsVisibility();
const noteId = treeService.getNoteIdFromUrl(suggestion.notePath);
if (noteId) {
setDefaultLinkTitle(noteId);
}
});
this.$autoComplete.on("autocomplete:externallinkselected", (event: JQuery.Event, suggestion: Suggestion) => {
if (!suggestion.externalLink) {
return false;
}
this.updateTitleSettingsVisibility();
this.$linkTitle.val(suggestion.externalLink);
});
this.$autoComplete.on("autocomplete:cursorchanged", (event: JQuery.Event, suggestion: Suggestion) => {
if (suggestion.externalLink) {
this.$linkTitle.val(suggestion.externalLink);
} else {
const noteId = treeService.getNoteIdFromUrl(suggestion.notePath!);
if (noteId) {
setDefaultLinkTitle(noteId);
}
}
});
if (text && text.trim()) {
noteAutocompleteService.setText(this.$autoComplete, text);
} else {
noteAutocompleteService.showRecentNotes(this.$autoComplete);
}
this.$autoComplete.trigger("focus").trigger("select"); // to be able to quickly remove entered text
}
private getLinkType() {
if (this.$autoComplete.getSelectedExternalLink()) {
return "external-link";
}
return this.$addLinkTitleSettings.find("input[type=radio]:checked").val();
}
private updateTitleSettingsVisibility() {
const linkType = this.getLinkType();
this.$addLinkTitleFormGroup.toggle(linkType !== "reference-link");
this.$addLinkTitleRadios.toggle(linkType !== "external-link");
}
}

View File

@ -0,0 +1,147 @@
import { EventData } from "../../components/app_context";
import { closeActiveDialog, openDialog } from "../../services/dialog";
import { t } from "../../services/i18n";
import Modal from "../react/Modal";
import ReactBasicWidget from "../react/ReactBasicWidget";
import Button from "../react/Button";
import FormRadioGroup from "../react/FormRadioGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { useState } from "preact/hooks";
import tree from "../../services/tree";
import { useEffect } from "react";
import { Suggestion } from "../../services/note_autocomplete";
import type { default as TextTypeWidget } from "../type_widgets/editable_text.js";
import { logError } from "../../services/ws";
type LinkType = "reference-link" | "external-link" | "hyper-link";
interface AddLinkDialogProps {
text?: string;
textTypeWidget?: TextTypeWidget;
}
function AddLinkDialogComponent({ text: _text, textTypeWidget }: AddLinkDialogProps) {
const [ text, setText ] = useState(_text ?? "");
const [ linkTitle, setLinkTitle ] = useState("");
const hasSelection = textTypeWidget?.hasSelection();
const [ linkType, setLinkType ] = useState<LinkType>(hasSelection ? "hyper-link" : "reference-link");
const [ suggestion, setSuggestion ] = useState<Suggestion>(null);
async function setDefaultLinkTitle(noteId: string) {
const noteTitle = await tree.getNoteTitle(noteId);
setLinkTitle(noteTitle);
}
function resetExternalLink() {
if (linkType === "external-link") {
setLinkType("reference-link");
}
}
useEffect(() => {
if (!suggestion) {
resetExternalLink();
return;
}
if (suggestion.notePath) {
const noteId = tree.getNoteIdFromUrl(suggestion.notePath);
if (noteId) {
setDefaultLinkTitle(noteId);
}
resetExternalLink();
}
if (suggestion.externalLink) {
setLinkTitle(suggestion.externalLink);
setLinkType("external-link");
}
}, [suggestion]);
function onSubmit() {
if (suggestion.notePath) {
// Handle note link
closeActiveDialog();
textTypeWidget?.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestion.externalLink) {
// Handle external link
closeActiveDialog();
textTypeWidget?.addLink(suggestion.externalLink, linkTitle, true);
} else {
logError("No link to add.");
}
}
return (
<Modal
className="add-link-dialog"
size="lg"
maxWidth={1000}
title={t("add_link.add_link")}
helpPageId="QEAPj01N5f7w"
footer={<Button text={t("add_link.button_add_link")} keyboardShortcut="Enter" />}
onSubmit={onSubmit}
onHidden={() => setSuggestion(null)}
>
<div className="form-group">
<label htmlFor="add-link-note-autocomplete">{t("add_link.note")}</label>
<NoteAutocomplete
text={text}
allowExternalLinks
allowCreatingNotes
onChange={setSuggestion}
/>
</div>
{!hasSelection && (
<div className="add-link-title-settings">
{(linkType !== "external-link") && (
<>
<FormRadioGroup
name="link-type"
currentValue={linkType}
values={[
{ value: "reference-link", label: t("add_link.link_title_mirrors") },
{ value: "hyper-link", label: t("add_link.link_title_arbitrary") }
]}
onChange={(newValue) => setLinkType(newValue as LinkType)}
/>
</>
)}
{(linkType !== "reference-link" && (
<div className="add-link-title-form-group form-group">
<br/>
<label>
{t("add_link.link_title")}
<input className="link-title form-control" style={{ width: "100%" }}
value={linkTitle}
onInput={(e: any) => setLinkTitle(e.target.value)}
/>
</label>
</div>
))}
</div>
)}
</Modal>
);
}
export default class AddLinkDialog extends ReactBasicWidget {
private props: AddLinkDialogProps = {};
get component() {
return <AddLinkDialogComponent {...this.props} />;
}
async showAddLinkDialogEvent({ textTypeWidget, text = "" }: EventData<"showAddLinkDialog">) {
this.props.text = text;
this.props.textTypeWidget = textTypeWidget;
this.doRender();
await openDialog(this.$widget);
}
}

View File

@ -3,7 +3,7 @@ import { useRef } from "preact/hooks";
interface ButtonProps {
/** Reference to the button element. Mostly useful for requesting focus. */
buttonRef: RefObject<HTMLButtonElement>;
buttonRef?: RefObject<HTMLButtonElement>;
text: string;
className?: string;
keyboardShortcut?: string;

View File

@ -37,7 +37,7 @@ export default function Modal({ children, className, size, title, footer, onShow
}
}
});
}
}
const style: CSSProperties = {};
if (maxWidth) {
@ -51,7 +51,7 @@ export default function Modal({ children, className, size, title, footer, onShow
<div className="modal-header">
<h5 className="modal-title">{title}</h5>
{helpPageId && (
<button className="help-button" type="button" data-in-app-help={helpPageId} title={t("branch_prefix.help_on_tree_prefix")}>?</button>
<button className="help-button" type="button" data-in-app-help={helpPageId} title={t("modal.help_title")}>?</button>
)}
<button type="button" className="btn-close" data-bs-dismiss="modal" aria-label={t("modal.close")}></button>
</div>

View File

@ -0,0 +1,48 @@
import { useRef } from "preact/hooks";
import { t } from "../../services/i18n";
import { use, useEffect } from "react";
import note_autocomplete, { type Suggestion } from "../../services/note_autocomplete";
interface NoteAutocompleteProps {
text?: string;
allowExternalLinks?: boolean;
allowCreatingNotes?: boolean;
onChange?: (suggestion: Suggestion) => void;
}
export default function NoteAutocomplete({ text, allowCreatingNotes, allowExternalLinks, onChange }: NoteAutocompleteProps) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
note_autocomplete.initNoteAutocomplete($autoComplete, {
allowExternalLinks,
allowCreatingNotes
});
if (onChange) {
$autoComplete.on("autocomplete:noteselected", (_e, suggestion) => onChange(suggestion));
$autoComplete.on("autocomplete:externallinkselected", (_e, suggestion) => onChange(suggestion));
}
}, [allowExternalLinks, allowCreatingNotes]);
useEffect(() => {
if (!ref.current) return;
if (text) {
const $autoComplete = $(ref.current);
note_autocomplete.setText($autoComplete, text);
} else {
ref.current.value = "";
}
}, [text]);
return (
<div className="input-group">
<input
ref={ref}
className="note-autocomplete form-control"
placeholder={t("add_link.search_note")} />
</div>
);
}