diff --git a/apps/client/package.json b/apps/client/package.json index d0ce3129824..263a028270c 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,6 +16,7 @@ "circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular" }, "dependencies": { + "@algolia/autocomplete-js": "1.19.6", "@excalidraw/excalidraw": "0.18.0", "@fullcalendar/core": "6.1.20", "@fullcalendar/daygrid": "6.1.20", @@ -43,8 +44,7 @@ "@univerjs/preset-sheets-note": "0.18.0", "@univerjs/preset-sheets-sort": "0.18.0", "@univerjs/presets": "0.18.0", - "@zumer/snapdom": "2.6.0", - "autocomplete.js": "0.38.1", + "@zumer/snapdom": "2.5.0", "bootstrap": "5.3.8", "boxicons": "2.1.4", "clsx": "2.1.1", diff --git a/apps/client/src/components/entrypoints.ts b/apps/client/src/components/entrypoints.ts index 8fc4e1b3d5d..6c880fa2c57 100644 --- a/apps/client/src/components/entrypoints.ts +++ b/apps/client/src/components/entrypoints.ts @@ -1,5 +1,6 @@ import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons"; +import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js"; import bundleService from "../services/bundle.js"; import dateNoteService from "../services/date_notes.js"; import froca from "../services/froca.js"; @@ -197,7 +198,7 @@ export default class Entrypoints extends Component { hideAllPopups() { if (utils.isDesktop()) { - $(".aa-input").autocomplete("close"); + closeAllHeadlessAutocompletes(); } } diff --git a/apps/client/src/components/tab_manager.ts b/apps/client/src/components/tab_manager.ts index 3cf06a7793b..6ba938d2db0 100644 --- a/apps/client/src/components/tab_manager.ts +++ b/apps/client/src/components/tab_manager.ts @@ -1,15 +1,16 @@ -import Component from "./component.js"; -import SpacedUpdate from "../services/spaced_update.js"; -import server from "../services/server.js"; -import options from "../services/options.js"; +import type FNote from "../entities/fnote.js"; +import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js"; import froca from "../services/froca.js"; +import linkService from "../services/link.js"; +import options from "../services/options.js"; +import server from "../services/server.js"; +import SpacedUpdate from "../services/spaced_update.js"; import treeService from "../services/tree.js"; -import NoteContext from "./note_context.js"; -import appContext from "./app_context.js"; import Mutex from "../utils/mutex.js"; -import linkService from "../services/link.js"; import type { EventData } from "./app_context.js"; -import type FNote from "../entities/fnote.js"; +import appContext from "./app_context.js"; +import Component from "./component.js"; +import NoteContext from "./note_context.js"; interface TabState { contexts: NoteContext[]; @@ -429,10 +430,7 @@ export default class TabManager extends Component { } // close dangling autocompletes after closing the tab - const $autocompleteEl = $(".aa-input"); - if ("autocomplete" in $autocompleteEl) { - $autocompleteEl.autocomplete("close"); - } + closeAllHeadlessAutocompletes(); // close dangling tooltips $("body > div.tooltip").remove(); diff --git a/apps/client/src/desktop.ts b/apps/client/src/desktop.ts index 6f22d3fc7bc..b1834524913 100644 --- a/apps/client/src/desktop.ts +++ b/apps/client/src/desktop.ts @@ -1,5 +1,3 @@ -import "autocomplete.js/index_jquery.js"; - import type ElectronRemote from "@electron/remote"; import type Electron from "electron"; diff --git a/apps/client/src/index.ts b/apps/client/src/index.ts index fba6e17d7e3..c5fae97d4c0 100644 --- a/apps/client/src/index.ts +++ b/apps/client/src/index.ts @@ -16,17 +16,6 @@ async function initJQuery() { const $ = (await import("jquery")).default; window.$ = $; window.jQuery = $; - - // Polyfill removed jQuery methods for autocomplete.js compatibility - ($ as any).isArray = Array.isArray; - ($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; }; - ($ as any).isPlainObject = function(obj: any) { - if (obj == null || typeof obj !== 'object') { return false; } - const proto = Object.getPrototypeOf(obj); - if (proto === null) { return true; } - const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor; - return typeof Ctor === 'function' && Ctor === Object; - }; } async function setupGlob() { diff --git a/apps/client/src/mobile.ts b/apps/client/src/mobile.ts index ed84fa3706f..e08750373b3 100644 --- a/apps/client/src/mobile.ts +++ b/apps/client/src/mobile.ts @@ -1,5 +1,3 @@ -import "autocomplete.js/index_jquery.js"; - import appContext from "./components/app_context.js"; import glob from "./services/glob.js"; import noteAutocompleteService from "./services/note_autocomplete.js"; diff --git a/apps/client/src/runtime.ts b/apps/client/src/runtime.ts index cab174a76d0..4c82481b170 100644 --- a/apps/client/src/runtime.ts +++ b/apps/client/src/runtime.ts @@ -8,17 +8,6 @@ async function loadBootstrap() { } } -// Polyfill removed jQuery methods for autocomplete.js compatibility -($ as any).isArray = Array.isArray; -($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; }; -($ as any).isPlainObject = function(obj: any) { - if (obj == null || typeof obj !== 'object') { return false; } - const proto = Object.getPrototypeOf(obj); - if (proto === null) { return true; } - const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor; - return typeof Ctor === 'function' && Ctor === Object; -}; - (window as any).$ = $; (window as any).jQuery = $; await loadBootstrap(); diff --git a/apps/client/src/services/attribute_autocomplete.spec.ts b/apps/client/src/services/attribute_autocomplete.spec.ts new file mode 100644 index 00000000000..eb35790723b --- /dev/null +++ b/apps/client/src/services/attribute_autocomplete.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { shouldAutocompleteHandleEnterKey } from "./attribute_autocomplete.js"; + +describe("attribute autocomplete enter handling", () => { + it("delegates plain Enter when the panel is open and an item is active", () => { + expect(shouldAutocompleteHandleEnterKey( + { key: "Enter", ctrlKey: false, metaKey: false }, + { isPanelOpen: true, hasActiveItem: true } + )).toBe(true); + }); + + it("does not delegate plain Enter when there is no active suggestion", () => { + expect(shouldAutocompleteHandleEnterKey( + { key: "Enter", ctrlKey: false, metaKey: false }, + { isPanelOpen: true, hasActiveItem: false } + )).toBe(false); + }); + + it("does not delegate plain Enter when the panel is closed", () => { + expect(shouldAutocompleteHandleEnterKey( + { key: "Enter", ctrlKey: false, metaKey: false }, + { isPanelOpen: false, hasActiveItem: false } + )).toBe(false); + }); + + it("does not delegate Ctrl+Enter even when an item is active", () => { + expect(shouldAutocompleteHandleEnterKey( + { key: "Enter", ctrlKey: true, metaKey: false }, + { isPanelOpen: true, hasActiveItem: true } + )).toBe(false); + }); + + it("does not delegate Cmd+Enter even when an item is active", () => { + expect(shouldAutocompleteHandleEnterKey( + { key: "Enter", ctrlKey: false, metaKey: true }, + { isPanelOpen: true, hasActiveItem: true } + )).toBe(false); + }); + + it("ignores non-Enter keys", () => { + expect(shouldAutocompleteHandleEnterKey( + { key: "ArrowDown", ctrlKey: false, metaKey: false }, + { isPanelOpen: false, hasActiveItem: false } + )).toBe(true); + }); +}); diff --git a/apps/client/src/services/attribute_autocomplete.ts b/apps/client/src/services/attribute_autocomplete.ts index 22d99d49b8c..0da8ad06074 100644 --- a/apps/client/src/services/attribute_autocomplete.ts +++ b/apps/client/src/services/attribute_autocomplete.ts @@ -1,114 +1,450 @@ +import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core"; +import { createAutocomplete } from "@algolia/autocomplete-core"; + import type { AttributeType } from "../entities/fattribute.js"; +import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js"; import server from "./server.js"; -interface InitOptions { +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface NameItem extends BaseItem { + name: string; +} + +export function shouldAutocompleteHandleEnterKey( + event: Pick, + { isPanelOpen, hasActiveItem }: { isPanelOpen: boolean; hasActiveItem: boolean } +) { + if (event.key !== "Enter") { + return true; + } + + if (event.ctrlKey || event.metaKey) { + return false; + } + + return isPanelOpen && hasActiveItem; +} + +interface InitAttributeNameOptions { + /** The element where the user types */ $el: JQuery; attributeType?: AttributeType | (() => AttributeType); open: boolean; - nameCallback?: () => string; + /** Called when the user selects a value or the panel closes */ + onValueChange?: (value: string) => void; } -/** - * @param $el - element on which to init autocomplete - * @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes - * @param open - should the autocomplete be opened after init? - */ -function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) { - if (!$el.hasClass("aa-input")) { - $el.autocomplete( - { - appendTo: document.querySelector("body"), - hint: false, - openOnFocus: true, - minLength: 0, - tabAutocomplete: false - }, - [ - { - displayKey: "name", - // disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type - cache: false, - source: async (term, cb) => { - const type = typeof attributeType === "function" ? attributeType() : attributeType; +// --------------------------------------------------------------------------- +// Instance tracking +// --------------------------------------------------------------------------- - const names = await server.get(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`); - const result = names.map((name) => ({ name })); +interface ManagedInstance { + autocomplete: CoreAutocompleteApi; + panelEl: HTMLElement; + cleanup: () => void; +} - cb(result); - } - } - ] - ); +const instanceMap = new WeakMap(); + +function renderItems( + panelEl: HTMLElement, + items: NameItem[], + activeItemId: number | null, + onSelect: (item: NameItem) => void, + onActivate: (index: number) => void, + onDeactivate: () => void +): void { + panelEl.innerHTML = ""; + if (items.length === 0) { + panelEl.style.display = "none"; + return; + } + const list = document.createElement("ul"); + list.className = "aa-core-list"; + items.forEach((item, index) => { + const li = document.createElement("li"); + li.className = "aa-core-item"; + if (index === activeItemId) { + li.classList.add("aa-core-item--active"); + } + li.textContent = item.name; + li.addEventListener("mousemove", () => { + if (activeItemId === index) { + return; + } - $el.on("autocomplete:opened", () => { - if ($el.attr("readonly")) { - $el.autocomplete("close"); + onActivate(index); + }); + li.addEventListener("mouseleave", (event) => { + const relatedTarget = event.relatedTarget; + if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) { + return; } + + onDeactivate(); + }); + li.addEventListener("mousedown", (e) => { + e.preventDefault(); // prevent input blur + e.stopPropagation(); + onSelect(item); }); + list.appendChild(li); + }); + panelEl.appendChild(list); +} + +// --------------------------------------------------------------------------- +// Attribute name autocomplete — new (autocomplete-core, headless) +// --------------------------------------------------------------------------- + +function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) { + const inputEl = $el[0] as HTMLInputElement; + const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi) => { + autocomplete.setQuery(inputEl.value || ""); + }; + + // Already initialized — just open if requested + if (instanceMap.has(inputEl)) { + if (open) { + const inst = instanceMap.get(inputEl)!; + syncQueryFromInputValue(inst.autocomplete); + inst.autocomplete.setIsOpen(true); + inst.autocomplete.refresh(); + } + return; } + const panelController = createHeadlessPanelController({ inputEl }); + const { panelEl } = panelController; + + let isPanelOpen = false; + let hasActiveItem = false; + + const autocomplete = createAutocomplete({ + openOnFocus: true, + defaultActiveItemId: 0, + shouldPanelOpen() { + return true; + }, + + getSources({ query }) { + return [ + withHeadlessSourceDefaults({ + sourceId: "attribute-names", + getItems() { + const type = typeof attributeType === "function" ? attributeType() : attributeType; + return server + .get(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`) + .then((names) => names.map((name) => ({ name }))); + }, + getItemInputValue({ item }) { + return item.name; + }, + onSelect({ item }) { + inputEl.value = item.name; + autocomplete.setQuery(item.name); + autocomplete.setIsOpen(false); + onValueChange?.(item.name); + }, + }), + ]; + }, + + onStateChange({ state }) { + isPanelOpen = state.isOpen; + hasActiveItem = state.activeItemId !== null; + + // Render items + const collections = state.collections; + const items = collections.length > 0 ? (collections[0].items as NameItem[]) : []; + const activeId = state.activeItemId ?? null; + + if (state.isOpen && items.length > 0) { + renderItems( + panelEl, + items, + activeId, + (item) => { + inputEl.value = item.name; + autocomplete.setQuery(item.name); + autocomplete.setIsOpen(false); + onValueChange?.(item.name); + }, + (index) => { + autocomplete.setActiveItemId(index); + }, + () => { + autocomplete.setActiveItemId(null); + } + ); + panelController.startPositioning(); + } else { + panelController.hide(); + } + + if (!state.isOpen) { + panelController.hide(); + } + }, + }); + + const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + }); + + const cleanupInputBindings = bindAutocompleteInput({ + inputEl, + autocomplete, + onInput(e, handlers) { + handlers.onChange(e as any); + }, + onFocus(e, handlers) { + syncQueryFromInputValue(autocomplete); + handlers.onFocus(e as any); + }, + onBlur() { + // Delay to allow mousedown on panel items + setTimeout(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + onValueChange?.(inputEl.value); + }, 50); + }, + onKeyDown(e, handlers) { + if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) { + return; + } + + if (e.key === "Enter") { + // Prevent the enter key from propagating to parent dialogs + // (which might interpret it as "submit" or "save and close") + e.stopPropagation(); + } + + handlers.onKeyDown(e as any); + } + }); + + const cleanup = () => { + unregisterGlobalCloser(); + cleanupInputBindings(); + panelController.destroy(); + }; + + instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); + if (open) { - $el.autocomplete("open"); + syncQueryFromInputValue(autocomplete); + autocomplete.setIsOpen(true); + autocomplete.refresh(); + panelController.startPositioning(); } } -async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) { - if ($el.hasClass("aa-input")) { - // we reinit every time because autocomplete seems to have a bug where it retains state from last - // open even though the value was reset - $el.autocomplete("destroy"); - } - let attributeName = ""; - if (nameCallback) { - attributeName = nameCallback(); - } - if (attributeName.trim() === "") { - return; - } +// --------------------------------------------------------------------------- +// Label value autocomplete (headless autocomplete-core) +// --------------------------------------------------------------------------- - const attributeValues = (await server.get(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute })); +interface LabelValueInitOptions { + $el: JQuery; + open: boolean; + nameCallback?: () => string; + onValueChange?: (value: string) => void; +} + +function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) { + const inputEl = $el[0] as HTMLInputElement; + const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi) => { + autocomplete.setQuery(inputEl.value || ""); + }; - if (attributeValues.length === 0) { + if (instanceMap.has(inputEl)) { + if (open) { + const inst = instanceMap.get(inputEl)!; + syncQueryFromInputValue(inst.autocomplete); + inst.autocomplete.setIsOpen(true); + inst.autocomplete.refresh(); + } return; } - $el.autocomplete( - { - appendTo: document.querySelector("body"), - hint: false, - openOnFocus: false, // handled manually - minLength: 0, - tabAutocomplete: false + const panelController = createHeadlessPanelController({ inputEl }); + const { panelEl } = panelController; + + let isPanelOpen = false; + let hasActiveItem = false; + let isSelecting = false; + + let cachedAttributeName = ""; + let cachedAttributeValues: NameItem[] = []; + + const handleSelect = (item: NameItem) => { + isSelecting = true; + inputEl.value = item.name; + inputEl.dispatchEvent(new Event("input", { bubbles: true })); + autocomplete.setQuery(item.name); + autocomplete.setIsOpen(false); + onValueChange?.(item.name); + isSelecting = false; + + setTimeout(() => { + // Preserve the legacy contract: several consumers still commit the + // selected value from their existing Enter key handlers instead of + // listening to the autocomplete selection event directly. + inputEl.dispatchEvent(new KeyboardEvent("keydown", { + key: "Enter", + code: "Enter", + keyCode: 13, + which: 13, + bubbles: true, + cancelable: true + })); + }, 0); + }; + + const autocomplete = createAutocomplete({ + openOnFocus: true, + defaultActiveItemId: null, + shouldPanelOpen() { + return true; + }, + + getSources({ query }) { + return [ + withHeadlessSourceDefaults({ + sourceId: "attribute-values", + async getItems() { + const attributeName = nameCallback ? nameCallback() : ""; + if (!attributeName.trim()) { + return []; + } + + if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) { + cachedAttributeName = attributeName; + const values = await server.get(`attribute-values/${encodeURIComponent(attributeName)}`); + cachedAttributeValues = values.map((name) => ({ name })); + } + + const q = query.toLowerCase(); + return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q)); + }, + getItemInputValue({ item }) { + return item.name; + }, + onSelect({ item }) { + handleSelect(item); + }, + }), + ]; }, - [ - { - displayKey: "value", - cache: false, - source: async function (term, cb) { - term = term.toLowerCase(); - const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term)); + onStateChange({ state }) { + isPanelOpen = state.isOpen; + hasActiveItem = state.activeItemId !== null; + + const collections = state.collections; + const items = collections.length > 0 ? (collections[0].items as NameItem[]) : []; + const activeId = state.activeItemId ?? null; - cb(filtered); - } + if (state.isOpen && items.length > 0) { + renderItems( + panelEl, + items, + activeId, + handleSelect, + (index) => { + autocomplete.setActiveItemId(index); + }, + () => { + autocomplete.setActiveItemId(null); + } + ); + panelController.startPositioning(); + } else { + panelController.hide(); } - ] - ); - $el.on("autocomplete:opened", () => { - if ($el.attr("readonly")) { - $el.autocomplete("close"); + if (!state.isOpen) { + panelController.hide(); + } + }, + }); + + const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + }); + + const cleanupInputBindings = bindAutocompleteInput({ + inputEl, + autocomplete, + onInput(e, handlers) { + if (!isSelecting) { + handlers.onChange(e as any); + } + }, + onFocus(e, handlers) { + const attributeName = nameCallback ? nameCallback() : ""; + if (attributeName !== cachedAttributeName) { + cachedAttributeName = ""; + cachedAttributeValues = []; + } + syncQueryFromInputValue(autocomplete); + handlers.onFocus(e as any); + }, + onBlur() { + setTimeout(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + onValueChange?.(inputEl.value); + }, 50); + }, + onKeyDown(e, handlers) { + if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) { + return; + } + + if (e.key === "Enter") { + e.stopPropagation(); + } + + handlers.onKeyDown(e as any); } }); + const cleanup = () => { + unregisterGlobalCloser(); + cleanupInputBindings(); + panelController.destroy(); + }; + + instanceMap.set(inputEl, { autocomplete, panelEl, cleanup }); + if (open) { - $el.autocomplete("open"); + syncQueryFromInputValue(autocomplete); + autocomplete.setIsOpen(true); + autocomplete.refresh(); + panelController.startPositioning(); + } +} + +export function destroyAutocomplete($el: JQuery | HTMLElement) { + const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement; + const instance = instanceMap.get(inputEl); + if (instance) { + instance.cleanup(); + instanceMap.delete(inputEl); } } export default { initAttributeNameAutocomplete, - initLabelValueAutocomplete + destroyAutocomplete, + initLabelValueAutocomplete, }; diff --git a/apps/client/src/services/autocomplete_core.spec.ts b/apps/client/src/services/autocomplete_core.spec.ts new file mode 100644 index 00000000000..18802bb7f24 --- /dev/null +++ b/apps/client/src/services/autocomplete_core.spec.ts @@ -0,0 +1,93 @@ +import $ from "jquery"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { + showSpy, + hideSpy, + updateDisplayedShortcutsSpy, + saveFocusedElementSpy, + focusSavedElementSpy +} = vi.hoisted(() => ({ + showSpy: vi.fn(), + hideSpy: vi.fn(), + updateDisplayedShortcutsSpy: vi.fn(), + saveFocusedElementSpy: vi.fn(), + focusSavedElementSpy: vi.fn() +})); + +vi.mock("bootstrap", () => ({ + Modal: { + getOrCreateInstance: vi.fn(() => ({ + show: showSpy, + hide: hideSpy + })) + } +})); + +vi.mock("./keyboard_actions.js", () => ({ + default: { + updateDisplayedShortcuts: updateDisplayedShortcutsSpy + } +})); + +vi.mock("./focus.js", () => ({ + saveFocusedElement: saveFocusedElementSpy, + focusSavedElement: focusSavedElementSpy +})); + +import { closeAllHeadlessAutocompletes, registerHeadlessAutocompleteCloser } from "./autocomplete_core.js"; +import { openDialog } from "./dialog.js"; + +describe("headless autocomplete closing", () => { + const unregisterClosers: Array<() => void> = []; + + beforeEach(() => { + vi.clearAllMocks(); + (window as any).glob = { + ...(window as any).glob, + activeDialog: null + }; + }); + + afterEach(() => { + while (unregisterClosers.length > 0) { + unregisterClosers.pop()?.(); + } + }); + + it("closes every registered closer and skips unregistered ones", () => { + const closer1 = vi.fn(); + const closer2 = vi.fn(); + const closer3 = vi.fn(); + + unregisterClosers.push(registerHeadlessAutocompleteCloser(closer1)); + const unregister2 = registerHeadlessAutocompleteCloser(closer2); + unregisterClosers.push(unregister2); + unregisterClosers.push(registerHeadlessAutocompleteCloser(closer3)); + + unregister2(); + + closeAllHeadlessAutocompletes(); + + expect(closer1).toHaveBeenCalledTimes(1); + expect(closer2).not.toHaveBeenCalled(); + expect(closer3).toHaveBeenCalledTimes(1); + }); + + it("closes registered autocompletes when a dialog finishes hiding", async () => { + const closer = vi.fn(); + unregisterClosers.push(registerHeadlessAutocompleteCloser(closer)); + + const dialogEl = document.createElement("div"); + const $dialog = $(dialogEl); + + await openDialog($dialog, false); + $dialog.trigger("hidden.bs.modal"); + + expect(showSpy).toHaveBeenCalledTimes(1); + expect(updateDisplayedShortcutsSpy).toHaveBeenCalledWith($dialog); + expect(saveFocusedElementSpy).toHaveBeenCalledTimes(1); + expect(closer).toHaveBeenCalledTimes(1); + expect(focusSavedElementSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/client/src/services/autocomplete_core.ts b/apps/client/src/services/autocomplete_core.ts new file mode 100644 index 00000000000..f35c45d2f40 --- /dev/null +++ b/apps/client/src/services/autocomplete_core.ts @@ -0,0 +1,195 @@ +import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core"; + +export const HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR = ".aa-core-panel"; + +type HeadlessSourceDefaults = Required, "getItemUrl" | "onActive" | "onResolve">>; + +const headlessAutocompleteClosers = new Set<() => void>(); + +export function withHeadlessSourceDefaults>( + source: TSource +): TSource & HeadlessSourceDefaults { + return { + getItemUrl() { + return undefined; + }, + onActive() { + // Headless consumers handle highlight side effects themselves. + }, + onResolve() { + // Headless consumers resolve and render items manually. + }, + ...source + } as TSource & HeadlessSourceDefaults; +} + +export function registerHeadlessAutocompleteCloser(close: () => void) { + headlessAutocompleteClosers.add(close); + + return () => { + headlessAutocompleteClosers.delete(close); + }; +} + +export function closeAllHeadlessAutocompletes() { + for (const close of Array.from(headlessAutocompleteClosers)) { + close(); + } +} + +interface HeadlessPanelControllerOptions { + inputEl: HTMLElement; + container?: HTMLElement | null; + className?: string; + containedClassName?: string; +} + +export function createHeadlessPanelController({ + inputEl, + container, + className = "aa-core-panel", + containedClassName = "aa-core-panel--contained" +}: HeadlessPanelControllerOptions) { + const panelEl = document.createElement("div"); + panelEl.className = className; + + const isContained = Boolean(container); + if (isContained) { + panelEl.classList.add(containedClassName); + container!.appendChild(panelEl); + } else { + document.body.appendChild(panelEl); + } + + panelEl.style.display = "none"; + + let rafId: number | null = null; + + const positionPanel = () => { + if (isContained) { + panelEl.style.position = "static"; + panelEl.style.top = ""; + panelEl.style.left = ""; + panelEl.style.width = "100%"; + panelEl.style.display = "block"; + return; + } + + const rect = inputEl.getBoundingClientRect(); + panelEl.style.position = "fixed"; + panelEl.style.top = `${rect.bottom}px`; + panelEl.style.left = `${rect.left}px`; + panelEl.style.width = `${rect.width}px`; + panelEl.style.display = "block"; + }; + + const stopPositioning = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + }; + + const startPositioning = () => { + if (isContained) { + positionPanel(); + return; + } + + if (rafId !== null) { + return; + } + + const update = () => { + positionPanel(); + rafId = requestAnimationFrame(update); + }; + + update(); + }; + + const hide = () => { + panelEl.style.display = "none"; + stopPositioning(); + }; + + const destroy = () => { + hide(); + panelEl.remove(); + }; + + return { + panelEl, + hide, + destroy, + startPositioning, + stopPositioning + }; +} + +type InputHandlers = ReturnType["getInputProps"]>; + +interface InputBinding { + type: string; + listener: (event: TEvent) => void; +} + +interface BindAutocompleteInputOptions { + inputEl: HTMLInputElement; + autocomplete: AutocompleteApi; + onInput?: (event: Event, handlers: InputHandlers) => void; + onFocus?: (event: Event, handlers: InputHandlers) => void; + onBlur?: (event: Event, handlers: InputHandlers) => void; + onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers) => void; + extraBindings?: InputBinding[]; +} + +export function bindAutocompleteInput({ + inputEl, + autocomplete, + onInput, + onFocus, + onBlur, + onKeyDown, + extraBindings = [] +}: BindAutocompleteInputOptions) { + const handlers = autocomplete.getInputProps({ inputElement: inputEl }); + + const bindings: InputBinding[] = [ + { + type: "input", + listener: (event: Event) => { + onInput?.(event, handlers); + } + }, + { + type: "focus", + listener: (event: Event) => { + onFocus?.(event, handlers); + } + }, + { + type: "blur", + listener: (event: Event) => { + onBlur?.(event, handlers); + } + }, + { + type: "keydown", + listener: (event: Event) => { + onKeyDown?.(event as KeyboardEvent, handlers); + } + }, + ...extraBindings + ]; + + bindings.forEach(({ type, listener }) => { + inputEl.addEventListener(type, listener as EventListener); + }); + + return () => { + bindings.forEach(({ type, listener }) => { + inputEl.removeEventListener(type, listener as EventListener); + }); + }; +} diff --git a/apps/client/src/services/dialog.ts b/apps/client/src/services/dialog.ts index 8711ec17511..6c301cf125b 100644 --- a/apps/client/src/services/dialog.ts +++ b/apps/client/src/services/dialog.ts @@ -1,9 +1,11 @@ import { Modal } from "bootstrap"; + import appContext from "../components/app_context.js"; import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js"; +import { InfoExtraProps } from "../widgets/dialogs/info.jsx"; import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js"; +import { closeAllHeadlessAutocompletes } from "./autocomplete_core.js"; import { focusSavedElement, saveFocusedElement } from "./focus.js"; -import { InfoExtraProps } from "../widgets/dialogs/info.jsx"; export async function openDialog($dialog: JQuery, closeActDialog = true, config?: Partial) { if (closeActDialog) { @@ -15,10 +17,7 @@ export async function openDialog($dialog: JQuery, closeActDialog = Modal.getOrCreateInstance($dialog[0], config).show(); $dialog.on("hidden.bs.modal", () => { - const $autocompleteEl = $(".aa-input"); - if ("autocomplete" in $autocompleteEl) { - $autocompleteEl.autocomplete("close"); - } + closeAllHeadlessAutocompletes(); if (!glob.activeDialog || glob.activeDialog === $dialog) { focusSavedElement(); diff --git a/apps/client/src/services/keyboard_actions.ts b/apps/client/src/services/keyboard_actions.ts index d447a90019a..712cf3de1de 100644 --- a/apps/client/src/services/keyboard_actions.ts +++ b/apps/client/src/services/keyboard_actions.ts @@ -1,8 +1,9 @@ -import server from "./server.js"; +import type { ActionKeyboardShortcut } from "@triliumnext/commons"; + import appContext from "../components/app_context.js"; -import shortcutService, { ShortcutBinding } from "./shortcuts.js"; import type Component from "../components/component.js"; -import type { ActionKeyboardShortcut } from "@triliumnext/commons"; +import server from "./server.js"; +import shortcutService, { ShortcutBinding } from "./shortcuts.js"; const keyboardActionRepo: Record = {}; @@ -51,7 +52,10 @@ async function setupActionsForElement(scope: string, $el: JQuery, c getActionsForScope("window").then((actions) => { for (const action of actions) { for (const shortcut of action.effectiveShortcuts ?? []) { - shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId })); + shortcutService.bindGlobalShortcut(shortcut, () => { + const ntxId = appContext.tabManager?.activeNtxId ?? null; + appContext.triggerCommand(action.actionName, { ntxId }); + }); } } }); diff --git a/apps/client/src/services/note_autocomplete.ts b/apps/client/src/services/note_autocomplete.ts index 9ca4fa86fb1..3f42f08649b 100644 --- a/apps/client/src/services/note_autocomplete.ts +++ b/apps/client/src/services/note_autocomplete.ts @@ -1,10 +1,15 @@ -import server from "./server.js"; +import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core"; +import { createAutocomplete } from "@algolia/autocomplete-core"; +import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; +import { type ComponentChild, h, render } from "preact"; + import appContext from "../components/app_context.js"; -import noteCreateService from "./note_create.js"; +import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js"; +import commandRegistry from "./command_registry.js"; import froca from "./froca.js"; import { t } from "./i18n.js"; -import commandRegistry from "./command_registry.js"; -import type { MentionFeedObjectItem } from "@triliumnext/ckeditor5"; +import noteCreateService from "./note_create.js"; +import server from "./server.js"; // this key needs to have this value, so it's hit by the tooltip const SELECTED_NOTE_PATH_KEY = "data-note-path"; @@ -24,7 +29,7 @@ function getSearchDelay(notesCount: number): number { let searchDelay = getSearchDelay(notesCount); // TODO: Deduplicate with server. -export interface Suggestion { +export interface Suggestion extends BaseItem { noteTitle?: string; externalLink?: string; notePathTitle?: string; @@ -54,33 +59,244 @@ export interface Options { isCommandPalette?: boolean; } -async function autocompleteSourceForCKEditor(queryText: string) { - return await new Promise((res, rej) => { - autocompleteSource( - queryText, - (rows) => { - res( - rows.map((row) => { - return { - action: row.action, - noteTitle: row.noteTitle, - id: `@${row.notePathTitle}`, - name: row.notePathTitle || "", - link: `#${row.notePath}`, - notePath: row.notePath, - highlightedNotePathTitle: row.highlightedNotePathTitle - }; - }) - ); +// --- Headless Autocomplete Helpers --- +interface ManagedInstance { + autocomplete: CoreAutocompleteApi; + panelEl: HTMLElement; + clearCursor: () => void; + isPanelOpen: () => boolean; + suppressNextClosedReset: () => void; + showQuery: (query: string) => void; + openRecentNotes: () => void; + cleanup: () => void; +} + +const INSTANCE_KEY = Symbol("noteAutocompleteInstance"); + +type ManagedAutocompleteElement = HTMLElement & { + [INSTANCE_KEY]?: ManagedInstance; +}; + +function getManagedInstanceForElement(inputEl: HTMLElement | null | undefined): ManagedInstance | null { + if (!inputEl) { + return null; + } + + return (inputEl as ManagedAutocompleteElement)[INSTANCE_KEY] ?? null; +} + +function setManagedInstanceForElement(inputEl: HTMLElement, instance: ManagedInstance) { + (inputEl as ManagedAutocompleteElement)[INSTANCE_KEY] = instance; +} + +function clearManagedInstanceForElement(inputEl: HTMLElement) { + delete (inputEl as ManagedAutocompleteElement)[INSTANCE_KEY]; +} + +function renderHighlightedNodes(text: string, { allowBreaks = false, replaceBreaks = false }: { allowBreaks?: boolean; replaceBreaks?: boolean } = {}): ComponentChild[] { + const parser = new DOMParser(); + const doc = parser.parseFromString(text, "text/html"); + const safeOutput: ComponentChild[] = []; + let key = 0; + + const processNode = (node: Node) => { + if (node.nodeType === Node.TEXT_NODE) { + safeOutput.push(node.textContent || ""); + } else if (node.nodeType === Node.ELEMENT_NODE) { + const el = node as Element; + if (el.tagName === "B") { + safeOutput.push(h("b", { key: key++ }, el.textContent || "")); + } else if (el.tagName === "BR" && allowBreaks) { + if (replaceBreaks) { + safeOutput.push(" "); + safeOutput.push(h("span", { key: key++, className: "aa-core-separator" }, "\u00b7")); + safeOutput.push(" "); + } else { + safeOutput.push(h("br", { key: key++ })); + } + } else { + // If the tag is not allowed, just extract its text content securely + safeOutput.push(el.textContent || ""); + } + } + }; + + doc.body.childNodes.forEach(processNode); + return safeOutput; +} + +function renderHighlightedText(text: string): ComponentChild[] { + return renderHighlightedNodes(text); +} + +function renderAttributeSnippet(snippet: string): ComponentChild[] { + return renderHighlightedNodes(snippet, { allowBreaks: true, replaceBreaks: true }); +} + +function getSuggestionIconClass(item: Suggestion): string { + if (item.action === "search-notes") { + return "bx bx-search"; + } + if (item.action === "create-note") { + return "bx bx-plus"; + } + if (item.action === "external-link") { + return "bx bx-link-external"; + } + + return item.icon || "bx bx-note"; +} + +function getSuggestionInputValue(item: Suggestion): string { + return item.noteTitle || item.notePathTitle || item.externalLink || ""; +} + +function renderCommandSuggestion(item: Suggestion): ComponentChild { + const titleContent = item.highlightedNotePathTitle + ? renderHighlightedText(item.highlightedNotePathTitle) + : item.noteTitle || ""; + + return h("div", { className: "command-suggestion" }, [ + h("span", { className: `command-icon ${item.icon || "bx bx-terminal"}` }), + h("div", { className: "command-content" }, [ + h("div", { className: "command-name" }, titleContent), + item.commandDescription ? h("div", { className: "command-description" }, item.commandDescription) : null + ]), + item.commandShortcut ? h("kbd", { className: "command-shortcut" }, item.commandShortcut) : null + ]); +} + +function renderNoteSuggestion(item: Suggestion): ComponentChild { + const titleContent = item.highlightedNotePathTitle + ? renderHighlightedText(item.highlightedNotePathTitle) + : item.noteTitle || item.notePathTitle || item.externalLink || ""; + + return h("div", { + className: item.action === "search-notes" ? "note-suggestion search-notes-action" : "note-suggestion" + }, [ + h("span", { className: `icon ${getSuggestionIconClass(item)}` }), + h("span", { className: "text" }, [ + h("span", { className: "aa-core-primary-row" }, [ + h("span", { className: "search-result-title" }, titleContent), + item.action === "search-notes" ? h("kbd", { className: "aa-core-shortcut" }, "Ctrl+Enter") : null + ]), + item.highlightedAttributeSnippet + ? h("div", { className: "search-result-attributes" }, renderAttributeSnippet(item.highlightedAttributeSnippet)) + : null + ]) + ]); +} + +function renderSuggestion(item: Suggestion): ComponentChild { + if (item.action === "command") { + return renderCommandSuggestion(item); + } + + return renderNoteSuggestion(item); +} + +function createSuggestionSource(options: Options, onSelectItem: (item: Suggestion) => void) { + return withHeadlessSourceDefaults({ + sourceId: "note-suggestions", + async getItems({ query }: { query: string }) { + return await fetchResolvedSuggestions(query, options); + }, + getItemInputValue({ item }: { item: Suggestion }) { + return getSuggestionInputValue(item); + }, + onSelect({ item }: { item: Suggestion }) { + void onSelectItem(item); + } + }); +} + +function renderItems( + panelEl: HTMLElement, + items: Suggestion[], + activeId: number | null, + onSelect: (item: Suggestion) => void | Promise, + onActivate: (index: number) => void, + onDeactivate: () => void +) { + if (items.length === 0) { + render(null, panelEl); + panelEl.style.display = "none"; + return; + } + + render(h("div", { className: "aa-core-list aa-suggestions", role: "listbox" }, items.map((item, index) => { + const classNames = [ "aa-core-item", "aa-suggestion" ]; + if (item.action) { + classNames.push(`${item.action}-action`); + } + if (index === activeId) { + classNames.push("aa-core-item--active", "aa-cursor"); + } + + return h("div", { + key: `${item.action || "note"}-${item.notePath || item.externalLink || item.commandId || item.noteTitle || index}-${index}`, + className: classNames.join(" "), + role: "option", + "aria-selected": index === activeId, + "data-index": String(index), + onMouseMove: () => { + if (activeId === index) { + return; + } + + onDeactivate(); + window.setTimeout(() => { + onActivate(index); + }, 0); }, - { - allowCreatingNotes: true + onMouseLeave: (event: MouseEvent) => { + const relatedTarget = event.relatedTarget; + const currentTarget = event.currentTarget; + if (relatedTarget instanceof HTMLElement && currentTarget instanceof HTMLElement && currentTarget.contains(relatedTarget)) { + return; + } + + onDeactivate(); + }, + onMouseDown: (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void onSelect(item); } - ); + }, renderSuggestion(item)); + })), panelEl); + panelEl.style.display = "block"; +} + +async function autocompleteSourceForCKEditor(queryText: string) { + const rows = await fetchResolvedSuggestions(queryText, { allowCreatingNotes: true }); + return rows.map((row) => { + return { + action: row.action, + noteTitle: row.noteTitle, + id: `@${row.notePathTitle}`, + name: row.notePathTitle || "", + link: `#${row.notePath}`, + notePath: row.notePath, + highlightedNotePathTitle: row.highlightedNotePathTitle + }; }); } -async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void, options: Options = {}) { +function getSearchingSuggestion(term: string): Suggestion[] { + if (term.trim().length === 0) { + return []; + } + + return [ + { + noteTitle: term, + highlightedNotePathTitle: t("quick-search.searching") + } + ]; +} + +async function fetchResolvedSuggestions(term: string, options: Options = {}): Promise { // Check if we're in command mode if (options.isCommandPalette && term.startsWith(">")) { const commandQuery = term.substring(1).trim(); @@ -102,21 +318,14 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void icon: cmd.icon })); - cb(commandSuggestions); - return; + return commandSuggestions; } - const fastSearch = options.fastSearch === false ? false : true; + const fastSearch = options.fastSearch !== false; if (fastSearch === false) { if (term.trim().length === 0) { - return; + return []; } - cb([ - { - noteTitle: term, - highlightedNotePathTitle: t("quick-search.searching") - } - ]); } const activeNoteId = appContext.tabManager.getActiveContextNoteId(); @@ -142,7 +351,7 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void { action: "search-notes", noteTitle: term, - highlightedNotePathTitle: `${t("note_autocomplete.search-for", { term })} Ctrl+Enter` + highlightedNotePathTitle: t("note_autocomplete.search-for", { term }) } ]); } @@ -157,287 +366,520 @@ async function autocompleteSource(term: string, cb: (rows: Suggestion[]) => void ].concat(results); } - cb(results); + return results; } -function clearText($el: JQuery) { - searchDelay = 0; +async function fetchSuggestionsWithDelay(term: string, options: Options): Promise { + return await new Promise((resolve) => { + clearTimeout(debounceTimeoutId); + debounceTimeoutId = setTimeout(async () => { + resolve(await fetchResolvedSuggestions(term, options)); + }, searchDelay); + + if (searchDelay === 0) { + searchDelay = getSearchDelay(notesCount); + } + }); +} + +function resetSelectionState($el: JQuery) { $el.setSelectedNotePath(""); - $el.autocomplete("val", "").trigger("change"); + $el.setSelectedExternalLink(null); +} + +function getManagedInstance($el: JQuery): ManagedInstance | null { + const inputEl = $el[0] as HTMLInputElement | undefined; + return getManagedInstanceForElement(inputEl); +} + +async function handleSuggestionSelection( + $el: JQuery, + autocomplete: CoreAutocompleteApi, + inputEl: HTMLInputElement, + suggestion: Suggestion +) { + if (suggestion.action === "command") { + autocomplete.setIsOpen(false); + $el.trigger("autocomplete:commandselected", [suggestion]); + return; + } + + if (suggestion.action === "external-link") { + $el.setSelectedNotePath(null); + $el.setSelectedExternalLink(suggestion.externalLink ?? null); + inputEl.value = suggestion.externalLink ?? ""; + autocomplete.setIsOpen(false); + $el.trigger("autocomplete:externallinkselected", [suggestion]); + return; + } + + if (suggestion.action === "create-note") { + const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType(); + if (!success) { + return; + } + + const { note } = await noteCreateService.createNote(notePath || suggestion.parentNoteId, { + title: suggestion.noteTitle, + activate: false, + type: noteType, + templateNoteId + }); + + const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; + suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); + } + + if (suggestion.action === "search-notes") { + const searchString = suggestion.noteTitle; + autocomplete.setIsOpen(false); + await appContext.triggerCommand("searchNotes", { searchString }); + return; + } + + $el.setSelectedNotePath(suggestion.notePath || ""); + $el.setSelectedExternalLink(null); + inputEl.value = suggestion.noteTitle || getSuggestionInputValue(suggestion); + autocomplete.setIsOpen(false); + $el.trigger("autocomplete:noteselected", [suggestion]); +} + +export function clearText($el: JQuery) { + searchDelay = 0; + resetSelectionState($el); + const inputEl = $el[0] as HTMLInputElement; + const instance = getManagedInstance($el); + if (instance) { + if (instance.isPanelOpen()) { + instance.suppressNextClosedReset(); + } + inputEl.value = ""; + instance.clearCursor(); + instance.autocomplete.setQuery(""); + instance.autocomplete.setIsOpen(false); + instance.autocomplete.refresh(); + $el.trigger("change"); + } } function setText($el: JQuery, text: string) { - $el.setSelectedNotePath(""); - $el.autocomplete("val", text.trim()).autocomplete("open"); + resetSelectionState($el); + const instance = getManagedInstance($el); + if (instance) { + instance.showQuery(text.trim()); + } } function showRecentNotes($el: JQuery) { searchDelay = 0; - $el.setSelectedNotePath(""); - $el.autocomplete("val", ""); - $el.autocomplete("open"); + resetSelectionState($el); + const instance = getManagedInstance($el); + if (instance) { + instance.openRecentNotes(); + } $el.trigger("focus"); } function showAllCommands($el: JQuery) { searchDelay = 0; - $el.setSelectedNotePath(""); - $el.autocomplete("val", ">").autocomplete("open"); + resetSelectionState($el); + const instance = getManagedInstance($el); + if (instance) { + instance.showQuery(">"); + } } function fullTextSearch($el: JQuery, options: Options) { - const searchString = $el.autocomplete("val") as unknown as string; - if (options.fastSearch === false || searchString?.trim().length === 0) { + const inputEl = $el[0] as HTMLInputElement; + const searchString = inputEl.value; + if (options.fastSearch === false || searchString.trim().length === 0) { return; } $el.trigger("focus"); options.fastSearch = false; - $el.autocomplete("val", ""); - $el.setSelectedNotePath(""); searchDelay = 0; - $el.autocomplete("val", searchString); + resetSelectionState($el); + + const instance = getManagedInstance($el); + if (instance) { + instance.clearCursor(); + instance.autocomplete.setQuery(""); + inputEl.value = ""; + instance.showQuery(searchString); + } } function initNoteAutocomplete($el: JQuery, options?: Options) { - if ($el.hasClass("note-autocomplete-input")) { - // clear any event listener added in previous invocation of this function - $el.off("autocomplete:noteselected"); + $el.addClass("note-autocomplete-input"); + const inputEl = $el[0] as HTMLInputElement; + if (getManagedInstanceForElement(inputEl)) { + $el + .off("autocomplete:noteselected") + .off("autocomplete:externallinkselected") + .off("autocomplete:commandselected"); return $el; } options = options || {}; - - // Used to track whether the user is performing character composition with an input method (such as Chinese Pinyin, Japanese, Korean, etc.) and to avoid triggering a search during the composition process. let isComposingInput = false; - $el.on("compositionstart", () => { - isComposingInput = true; - }); - $el.on("compositionend", () => { - isComposingInput = false; - const searchString = $el.autocomplete("val") as unknown as string; - $el.autocomplete("val", ""); - $el.autocomplete("val", searchString); - }); - - $el.addClass("note-autocomplete-input"); - const $clearTextButton = $("").addClass("input-group-text input-clearer-button bx bxs-tag-x").prop("title", t("note_autocomplete.clear-text-field")); - - const $showRecentNotesButton = $("").addClass("input-group-text show-recent-notes-button bx bx-time").prop("title", t("note_autocomplete.show-recent-notes")); + const panelController = createHeadlessPanelController({ + inputEl, + container: options.container, + className: "aa-core-panel aa-dropdown-menu" + }); + const { panelEl } = panelController; + let currentQuery = inputEl.value; + let shouldAutoselectTopItem = false; + let shouldMirrorActiveItemToInput = false; + let wasPanelOpen = false; + let suppressNextClosedEmptyReset = false; + let shouldClearQueryAfterClose = false; + let suggestionRequestId = 0; + let lastRenderedItems: Suggestion[] = []; + let lastRenderedQuery = currentQuery; + + const clearCursor = () => { + shouldMirrorActiveItemToInput = false; + autocomplete.setActiveItemId(null); + inputEl.value = currentQuery; + }; - const $fullTextSearchButton = $("") - .addClass("input-group-text full-text-search-button bx bx-search") - .prop("title", `${t("note_autocomplete.full-text-search")} (Shift+Enter)`); + const suppressNextClosedReset = () => { + suppressNextClosedEmptyReset = true; + }; - const $goToSelectedNoteButton = $("").addClass("input-group-text go-to-selected-note-button bx bx-arrow-to-right"); + const prepareForQueryChange = () => { + shouldAutoselectTopItem = true; + shouldMirrorActiveItemToInput = false; + }; - if (!options.hideAllButtons) { - $el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton); - } + const rerunQuery = (query: string) => { + if (!query.trim().length) { + openRecentNotes(); + return; + } - if (!options.hideGoToSelectedNoteButton && !options.hideAllButtons) { - $el.after($goToSelectedNoteButton); - } + prepareForQueryChange(); + currentQuery = ""; + inputEl.value = ""; + autocomplete.setQuery(""); + showQuery(query); + }; - $clearTextButton.on("click", () => clearText($el)); + const onSelectItem = async (item: Suggestion) => { + await handleSuggestionSelection($el, autocomplete, inputEl, item); + }; - $showRecentNotesButton.on("click", (e) => { - showRecentNotes($el); + const source = createSuggestionSource(options, onSelectItem); - // this will cause the click not give focus to the "show recent notes" button - // this is important because otherwise input will lose focus immediately and not show the results - return false; - }); + const showQuery = (query: string) => { + prepareForQueryChange(); + inputEl.value = query; + autocomplete.setQuery(query); + autocomplete.setIsOpen(true); + autocomplete.refresh(); + }; - $fullTextSearchButton.on("click", (e) => { - fullTextSearch($el, options); - return false; - }); + const reopenCachedResults = (query: string) => { + if (lastRenderedItems.length === 0 || lastRenderedQuery !== query) { + return false; + } - let autocompleteOptions: AutoCompleteConfig = {}; - if (options.container) { - autocompleteOptions.dropdownMenuContainer = options.container; - autocompleteOptions.debug = true; // don't close on blur - } + shouldAutoselectTopItem = false; + shouldMirrorActiveItemToInput = false; + inputEl.value = query; + autocomplete.setActiveItemId(lastRenderedItems.length > 0 ? 0 : null); + autocomplete.setIsOpen(true); + return true; + }; - if (options.allowJumpToSearchNotes) { - $el.on("keydown", (event) => { - if (event.ctrlKey && event.key === "Enter") { - // Prevent Ctrl + Enter from triggering autoComplete. - event.stopImmediatePropagation(); - event.preventDefault(); - $el.trigger("autocomplete:selected", { action: "search-notes", noteTitle: $el.autocomplete("val") }); - } + const openRecentNotes = () => { + resetSelectionState($el); + prepareForQueryChange(); + inputEl.value = ""; + autocomplete.setQuery(""); + autocomplete.setActiveItemId(null); + + fetchResolvedSuggestions("", options).then((items) => { + autocomplete.setCollections([{ source, items }]); + autocomplete.setActiveItemId(items.length > 0 ? 0 : null); + autocomplete.setIsOpen(items.length > 0); }); - } - $el.on("keydown", async (event) => { - if (event.shiftKey && event.key === "Enter") { - // Prevent Enter from triggering autoComplete. - event.stopImmediatePropagation(); - event.preventDefault(); - fullTextSearch($el, options); - } - }); + }; - $el.autocomplete( - { - ...autocompleteOptions, - appendTo: document.querySelector("body"), - hint: false, - autoselect: true, - // openOnFocus has to be false, otherwise re-focus (after return from note type chooser dialog) forces - // re-querying of the autocomplete source which then changes the currently selected suggestion - openOnFocus: false, - minLength: 0, - tabAutocomplete: false + const autocomplete = createAutocomplete({ + openOnFocus: false, // Wait until we explicitly focus or type + // Old autocomplete.js used `autoselect: true`, so the first item + // should be immediately selectable when the panel opens. + defaultActiveItemId: 0, + shouldPanelOpen() { + return true; }, - [ - { - source: (term, cb) => { - clearTimeout(debounceTimeoutId); - debounceTimeoutId = setTimeout(() => { + + getSources({ query }) { + return [ + { + ...source, + async getItems() { if (isComposingInput) { - return; + return []; } - autocompleteSource(term, cb, options); - }, searchDelay); - if (searchDelay === 0) { - searchDelay = getSearchDelay(notesCount); - } - }, - displayKey: "notePathTitle", - templates: { - suggestion: (suggestion) => { - if (suggestion.action === "command") { - let html = `
`; - html += ``; - html += `
`; - html += `
${suggestion.highlightedNotePathTitle}
`; - if (suggestion.commandDescription) { - html += `
${suggestion.commandDescription}
`; - } - html += `
`; - if (suggestion.commandShortcut) { - html += `${suggestion.commandShortcut}`; - } - html += '
'; - return html; - } - // Add special class for search-notes action - const actionClass = suggestion.action === "search-notes" ? "search-notes-action" : ""; - - // Choose appropriate icon based on action - let iconClass = suggestion.icon ?? "bx bx-note"; - if (suggestion.action === "search-notes") { - iconClass = "bx bx-search"; - } else if (suggestion.action === "create-note") { - iconClass = "bx bx-plus"; - } else if (suggestion.action === "external-link") { - iconClass = "bx bx-link-external"; - } + if (options.fastSearch === false && query.trim().length > 0) { + const requestId = ++suggestionRequestId; + + void fetchSuggestionsWithDelay(query, options).then((items) => { + if (requestId !== suggestionRequestId || currentQuery !== query) { + return; + } - // Simplified HTML structure without nested divs - let html = `
`; - html += ``; - html += ``; - html += `${suggestion.highlightedNotePathTitle}`; + autocomplete.setCollections([{ source, items }]); + autocomplete.setIsOpen(items.length > 0); + }); - // Add attribute snippet inline if available - if (suggestion.highlightedAttributeSnippet) { - html += `${suggestion.highlightedAttributeSnippet}`; + return getSearchingSuggestion(query); } - html += ``; - html += `
`; - return html; + return await fetchSuggestionsWithDelay(query, options); } }, - // we can't cache identical searches because notes can be created / renamed, new recent notes can be added - cache: false + ]; + }, + + onStateChange({ state }) { + const collections = state.collections; + const items = collections.length > 0 ? (collections[0].items as Suggestion[]) : []; + const activeId = state.activeItemId ?? null; + const activeItem = activeId !== null ? items[activeId] : null; + currentQuery = state.query; + lastRenderedItems = items; + lastRenderedQuery = state.query; + const isPanelOpen = state.isOpen && items.length > 0; + + if (isPanelOpen !== wasPanelOpen) { + wasPanelOpen = isPanelOpen; + + if (isPanelOpen) { + $el.trigger("autocomplete:opened"); + + if (inputEl.readOnly) { + suppressNextClosedReset(); + autocomplete.setIsOpen(false); + return; + } + } else { + $el.trigger("autocomplete:closed"); + + if (suppressNextClosedEmptyReset) { + suppressNextClosedEmptyReset = false; + } else if (!String(inputEl.value).trim()) { + searchDelay = 0; + resetSelectionState($el); + currentQuery = ""; + inputEl.value = ""; + shouldClearQueryAfterClose = state.query.length > 0; + $el.trigger("change"); + } + } } - ] - ); - // TODO: Types fail due to "autocomplete:selected" not being registered in type definitions. - ($el as any).on("autocomplete:selected", async (event: Event, suggestion: Suggestion) => { - if (suggestion.action === "command") { - $el.autocomplete("close"); - $el.trigger("autocomplete:commandselected", [suggestion]); - return; - } + if (shouldClearQueryAfterClose) { + inputEl.value = ""; + shouldClearQueryAfterClose = false; + queueMicrotask(() => { + autocomplete.setQuery(""); + }); + } else if (activeItem && shouldMirrorActiveItemToInput) { + inputEl.value = getSuggestionInputValue(activeItem); + } else { + inputEl.value = state.query; + } - if (suggestion.action === "external-link") { - $el.setSelectedNotePath(null); - $el.setSelectedExternalLink(suggestion.externalLink); + if (isPanelOpen) { + renderItems(panelEl, items, activeId, (item) => { + void onSelectItem(item); + }, (index) => { + autocomplete.setActiveItemId(index); + }, () => { + clearCursor(); + }); + + if (shouldAutoselectTopItem && activeId === null) { + shouldAutoselectTopItem = false; + shouldMirrorActiveItemToInput = false; + autocomplete.setActiveItemId(0); + return; + } + + panelController.startPositioning(); + } else { + shouldAutoselectTopItem = false; + panelController.hide(); + } + }, + }); - $el.autocomplete("val", suggestion.externalLink); + const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + }); - $el.autocomplete("close"); + const onCompositionStart = () => { + isComposingInput = true; + }; + const onCompositionEnd = (e: any) => { + isComposingInput = false; + rerunQuery(inputEl.value); + }; - $el.trigger("autocomplete:externallinkselected", [suggestion]); + const cleanupInputBindings = bindAutocompleteInput({ + inputEl, + autocomplete, + onInput(e, handlers) { + const value = (e.currentTarget as HTMLInputElement).value; + if (value.trim().length === 0) { + openRecentNotes(); + return; + } - return; - } + prepareForQueryChange(); + handlers.onChange(e as any); + }, + onFocus(e, handlers) { + if (inputEl.readOnly) { + autocomplete.setIsOpen(false); + panelController.hide(); + return; + } - if (suggestion.action === "create-note") { - const { success, noteType, templateNoteId, notePath } = await noteCreateService.chooseNoteType(); - if (!success) { + handlers.onFocus(e as any); + + if (wasPanelOpen) { return; } - const { note } = await noteCreateService.createNote( notePath || suggestion.parentNoteId, { - title: suggestion.noteTitle, - activate: false, - type: noteType, - templateNoteId: templateNoteId - }); - - const hoistedNoteId = appContext.tabManager.getActiveContext()?.hoistedNoteId; - suggestion.notePath = note?.getBestNotePathString(hoistedNoteId); - } - if (suggestion.action === "search-notes") { - const searchString = suggestion.noteTitle; - appContext.triggerCommand("searchNotes", { searchString }); - return; - } + const value = inputEl.value.trim(); + if (value.length === 0) { + if (reopenCachedResults("")) { + return; + } - $el.setSelectedNotePath(suggestion.notePath); - $el.setSelectedExternalLink(null); + openRecentNotes(); + } else { + if (reopenCachedResults(inputEl.value)) { + return; + } - $el.autocomplete("val", suggestion.noteTitle); + showQuery(inputEl.value); + } + }, + onBlur() { + if (options.container) { + return; + } + setTimeout(() => { + autocomplete.setIsOpen(false); + panelController.hide(); + }, 50); + }, + onKeyDown(e, handlers) { + if (options.allowJumpToSearchNotes && e.ctrlKey && e.key === "Enter") { + e.stopImmediatePropagation(); + e.preventDefault(); + void handleSuggestionSelection($el, autocomplete, inputEl, { + action: "search-notes", + noteTitle: inputEl.value + }); + return; + } - $el.autocomplete("close"); + if (e.shiftKey && e.key === "Enter") { + e.stopImmediatePropagation(); + e.preventDefault(); + fullTextSearch($el, options); + return; + } - $el.trigger("autocomplete:noteselected", [suggestion]); - }); + if (e.key === "Enter" && !wasPanelOpen) { + // Do not pass the Enter key to autocomplete-core if the panel is closed. + // This prevents `preventDefault()` from being called inappropriately and + // allows the native form submission to work. + return; + } - $el.on("autocomplete:closed", () => { - if (!String($el.val())?.trim()) { - clearText($el); - } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + shouldMirrorActiveItemToInput = true; + } + handlers.onKeyDown(e as any); + }, + extraBindings: [ + { type: "compositionstart", listener: onCompositionStart as ((event: Event) => void) }, + { type: "compositionend", listener: onCompositionEnd as ((event: Event) => void) } + ] }); - $el.on("autocomplete:opened", () => { - if ($el.attr("readonly")) { - $el.autocomplete("close"); - } + const cleanup = () => { + unregisterGlobalCloser(); + cleanupInputBindings(); + render(null, panelEl); + panelController.destroy(); + }; + + setManagedInstanceForElement(inputEl, { + autocomplete, + panelEl, + clearCursor, + isPanelOpen: () => wasPanelOpen, + suppressNextClosedReset, + showQuery, + openRecentNotes, + cleanup }); - // clear any event listener added in previous invocation of this function - $el.off("autocomplete:noteselected"); + // Buttons UI logic + const $clearTextButton = $("
").addClass("input-group-text input-clearer-button bx bxs-tag-x").prop("title", t("note_autocomplete.clear-text-field")); + const $showRecentNotesButton = $("").addClass("input-group-text show-recent-notes-button bx bx-time").prop("title", t("note_autocomplete.show-recent-notes")); + const $fullTextSearchButton = $("").addClass("input-group-text full-text-search-button bx bx-search").prop("title", `${t("note_autocomplete.full-text-search")} (Shift+Enter)`); + const $goToSelectedNoteButton = $("").addClass("input-group-text go-to-selected-note-button bx bx-arrow-to-right"); + + if (!options.hideAllButtons) { + $el.after($clearTextButton).after($showRecentNotesButton).after($fullTextSearchButton); + } + if (!options.hideGoToSelectedNoteButton && !options.hideAllButtons) { + $el.after($goToSelectedNoteButton); + } + + $clearTextButton.on("click", () => clearText($el)); + $showRecentNotesButton.on("click", (e) => { + showRecentNotes($el); + return false; + }); + $fullTextSearchButton.on("click", (e) => { + fullTextSearch($el, options!); + return false; + }); return $el; } +export function destroyAutocomplete($el: JQuery | HTMLElement) { + const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement; + const instance = getManagedInstanceForElement(inputEl); + if (instance) { + instance.cleanup(); + clearManagedInstanceForElement(inputEl); + } +} + function init() { $.fn.getSelectedNotePath = function () { if (!String($(this).val())?.trim()) { return ""; - } else { - return $(this).attr(SELECTED_NOTE_PATH_KEY); } + return $(this).attr(SELECTED_NOTE_PATH_KEY); + }; $.fn.getSelectedNoteId = function () { @@ -461,9 +903,9 @@ function init() { $.fn.getSelectedExternalLink = function () { if (!String($(this).val())?.trim()) { return ""; - } else { - return $(this).attr(SELECTED_EXTERNAL_LINK_KEY); } + return $(this).attr(SELECTED_EXTERNAL_LINK_KEY); + }; $.fn.setSelectedExternalLink = function (externalLink: string | null) { @@ -473,10 +915,19 @@ function init() { $.fn.setNote = async function (noteId) { const note = noteId ? await froca.getNote(noteId, true) : null; + const $el = $(this as unknown as HTMLElement); + const instance = getManagedInstance($el); + const noteTitle = note ? note.title : ""; - $(this) - .val(note ? note.title : "") + $el + .val(noteTitle) .setSelectedNotePath(noteId); + + if (instance) { + instance.clearCursor(); + instance.autocomplete.setQuery(noteTitle); + instance.autocomplete.setIsOpen(false); + } }; } @@ -497,6 +948,8 @@ export function triggerRecentNotes(inputElement: HTMLInputElement | null | undef export default { autocompleteSourceForCKEditor, + clearText, + destroyAutocomplete, initNoteAutocomplete, showRecentNotes, showAllCommands, diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index 5a462b98046..1f3cc1df4b3 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -892,33 +892,6 @@ table.promoted-attributes-in-tooltip th { opacity: 1; } -.algolia-autocomplete { - width: calc(100% - 30px); - z-index: 2000 !important; -} - -.algolia-autocomplete-container .aa-dropdown-menu { - position: inherit !important; - overflow: auto; -} - -.algolia-autocomplete .aa-input, -.algolia-autocomplete .aa-hint { - width: 100%; -} - -.algolia-autocomplete .aa-dropdown-menu { - width: 100%; - background-color: var(--main-background-color); - border: 1px solid var(--main-border-color); - border-top: none; - z-index: 2000 !important; - max-height: 500px; - overflow: auto; - padding: 0; - margin: 0; -} - .aa-dropdown-menu .aa-suggestion { cursor: pointer; padding: 6px 16px; @@ -960,6 +933,153 @@ table.promoted-attributes-in-tooltip th { background-color: var(--active-item-background-color); } +/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */ + +.aa-core-panel { + z-index: 10000; + background-color: var(--main-background-color); + border: 1px solid var(--main-border-color); + border-top: none; + max-height: 500px; + overflow: auto; + padding: 0; + margin: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} + +.aa-core-panel.aa-dropdown-menu { + width: 100%; +} + +.aa-core-panel--contained { + position: static !important; + border: 0; + background: transparent; + box-shadow: none; +} + +.aa-core-list { + list-style: none; + padding: 0; + margin: 0; +} + +.aa-core-item { + cursor: pointer; + padding: 7px 16px; + margin: 0; + white-space: normal; +} + +.aa-core-item--active { + color: var(--active-item-text-color); + background-color: var(--active-item-background-color); +} + +.aa-core-item .note-suggestion { + display: flex; + align-items: flex-start; + gap: 8px; + width: 100%; +} + +.aa-core-item .icon, +.aa-core-item .command-icon { + flex-shrink: 0; + line-height: 1.4; + margin-top: 1px; +} + +.aa-core-item .text { + min-width: 0; + flex: 1; +} + +.aa-core-item .aa-core-primary-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.aa-core-item .search-result-title { + display: block; + min-width: 0; + line-height: 1.35; + word-break: break-word; + font-size: 1.02em; +} + +.aa-core-item .search-result-attributes { + display: block; + margin-top: 1px; + font-size: 0.8em; + color: var(--muted-text-color); + opacity: 0.65; + line-height: 1.2; + word-break: break-word; +} + +.aa-core-item .search-result-attributes { + padding-inline-start: 14px; +} + +.aa-core-item .aa-core-shortcut, +.aa-core-item kbd.command-shortcut { + flex-shrink: 0; + padding: 0; + border: 0; + background: transparent; + color: var(--muted-text-color); + font-family: inherit !important; + font-size: 0.8em; + opacity: 0.85; +} + +.aa-core-item .command-suggestion { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + font-size: 0.9em; +} + +.aa-core-item .command-content { + flex-grow: 1; + min-width: 0; +} + +.aa-core-item .command-name { + font-weight: bold; + line-height: 1.35; +} + +.aa-core-item .command-description { + font-size: 0.8em; + line-height: 1.3; + opacity: 0.75; +} + +.aa-core-item .search-result-title b, +.aa-core-item .search-result-path b, +.aa-core-item .search-result-attributes b, +.aa-core-item .command-name b, +.aa-core-item .command-description b { + color: var(--admonition-warning-accent-color); + text-decoration: underline; +} + +.aa-core-item .aa-core-separator { + padding: 0 2px; +} + +.jump-to-note-results .aa-core-panel--contained { + max-height: calc(80vh - 200px); + overflow-y: auto; + overflow-x: hidden; + text-overflow: ellipsis; +} + .help-button { float: inline-end; background: none; diff --git a/apps/client/src/stylesheets/theme-next/pages.css b/apps/client/src/stylesheets/theme-next/pages.css index 42a831bae78..1d37973f158 100644 --- a/apps/client/src/stylesheets/theme-next/pages.css +++ b/apps/client/src/stylesheets/theme-next/pages.css @@ -128,8 +128,8 @@ margin-inline: auto; } -/* The search results list */ -.note-detail-empty span.aa-dropdown-menu { +/* The headless autocomplete panel rendered into the empty-note results container */ +.note-detail-empty .aa-core-panel--contained { margin-top: 1em; border: unset; } diff --git a/apps/client/src/types.d.ts b/apps/client/src/types.d.ts index f7673901c12..85ed355b5cf 100644 --- a/apps/client/src/types.d.ts +++ b/apps/client/src/types.d.ts @@ -6,7 +6,6 @@ import type { PrintReport } from "./print"; import type { lint } from "./services/eslint"; import type { Froca } from "./services/froca-interface"; import { Library } from "./services/library_loader"; -import { Suggestion } from "./services/note_autocomplete"; import server from "./services/server"; import utils from "./services/utils"; @@ -83,34 +82,7 @@ declare global { "note-load-progress": CustomEvent<{ progress: number }>; } - interface AutoCompleteConfig { - appendTo?: HTMLElement | null; - hint?: boolean; - openOnFocus?: boolean; - minLength?: number; - tabAutocomplete?: boolean; - autoselect?: boolean; - dropdownMenuContainer?: HTMLElement; - debug?: boolean; - } - - type AutoCompleteCallback = (values: AutoCompleteArg[]) => void; - - interface AutoCompleteArg { - name?: string; - value?: string; - notePathTitle?: string; - displayKey?: "name" | "value" | "notePathTitle"; - cache?: boolean; - source?: (term: string, cb: AutoCompleteCallback) => void, - templates?: { - suggestion: (suggestion: Suggestion) => string | undefined - } - } - interface JQuery { - autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery; - getSelectedNotePath(): string | undefined; getSelectedNoteId(): string | null; setSelectedNotePath(notePath: string | null | undefined); diff --git a/apps/client/src/widgets/PromotedAttributes.tsx b/apps/client/src/widgets/PromotedAttributes.tsx index 6a1a8640f20..7604990e234 100644 --- a/apps/client/src/widgets/PromotedAttributes.tsx +++ b/apps/client/src/widgets/PromotedAttributes.tsx @@ -8,6 +8,7 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from import NoteContext from "../components/note_context"; import FAttribute from "../entities/fattribute"; import FNote from "../entities/fnote"; +import attributeAutocompleteService from "../services/attribute_autocomplete"; import { Attribute } from "../services/attribute_parser"; import attributes from "../services/attributes"; import { t } from "../services/i18n"; @@ -36,8 +37,7 @@ interface CellProps { setCellToFocus(cell: Cell): void; } -type OnChangeEventData = TargetedEvent | InputEvent | JQuery.TriggeredEvent; -type OnChangeListener = (e: OnChangeEventData) => void | Promise; +type OnChangeEventData = TargetedEvent | InputEvent; export default function PromotedAttributes() { const { note, componentId, noteContext } = useNoteContext(); @@ -201,10 +201,9 @@ function LabelInput(props: CellProps & { inputId: string }) { }, [ cell, componentId, note, setCells ]); const extraInputProps: InputHTMLAttributes = {}; - useTextLabelAutocomplete(inputId, valueAttr, definition, (e) => { - if (e.currentTarget instanceof HTMLInputElement) { - setDraft(e.currentTarget.value); - } + useTextLabelAutocomplete(inputId, valueAttr, definition, async (value) => { + setDraft(value); + await updateAttribute(note, cell, componentId, value, setCells); }); // React to model changes. @@ -260,7 +259,7 @@ function LabelInput(props: CellProps & { inputId: string }) { className="open-external-link-button" icon="bx bx-window-open" title={t("promoted_attributes.open_external_link")} - onClick={(e) => { + onClick={() => { const inputEl = document.getElementById(inputId) as HTMLInputElement | null; const url = inputEl?.value; if (url) { @@ -415,55 +414,31 @@ function InputButton({ icon, className, title, onClick }: { ); } -function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onChangeListener: OnChangeListener) { - const [ attributeValues, setAttributeValues ] = useState<{ value: string }[] | null>(null); - - // Obtain data. +function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onValueChange: (value: string) => void) { useEffect(() => { if (definition.labelType !== "text") { return; } - server.get(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributesValues) => { - setAttributeValues(_attributesValues.map((attribute) => ({ value: attribute }))); - }); - }, [ definition.labelType, valueAttr.name ]); - - // Initialize autocomplete. - useEffect(() => { - if (attributeValues?.length === 0) return; const el = document.getElementById(inputId) as HTMLInputElement | null; - if (!el) return; + if (!el) { + return; + } const $input = $(el); - $input.autocomplete( - { - appendTo: document.querySelector("body"), - hint: false, - autoselect: false, - openOnFocus: true, - minLength: 0, - tabAutocomplete: false - }, - [ - { - displayKey: "value", - source (term, cb) { - term = term.toLowerCase(); - - const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term)); - - cb(filtered); - } - } - ] - ); - - $input.off("autocomplete:selected"); - $input.on("autocomplete:selected", onChangeListener); + attributeAutocompleteService.initLabelValueAutocomplete({ + $el: $input, + open: false, + nameCallback: () => valueAttr.name, + onValueChange: (value) => { + onValueChange(value); + } + }); - return () => $input.autocomplete("destroy"); - }, [ inputId, attributeValues, onChangeListener ]); + return () => { + attributeAutocompleteService.destroyAutocomplete($input); + }; + }, [ definition.labelType, inputId, onValueChange, valueAttr.name ]); } async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch>) { diff --git a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts index 73bf2cc2d6f..9c9696d8f1a 100644 --- a/apps/client/src/widgets/attribute_widgets/attribute_detail.ts +++ b/apps/client/src/widgets/attribute_widgets/attribute_detail.ts @@ -1,6 +1,7 @@ import appContext from "../../components/app_context.js"; import attributeAutocompleteService from "../../services/attribute_autocomplete.js"; import type { Attribute } from "../../services/attribute_parser.js"; +import { HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR } from "../../services/autocomplete_core.js"; import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js"; import { focusSavedElement, saveFocusedElement } from "../../services/focus.js"; import froca from "../../services/froca.js"; @@ -375,13 +376,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { } }); this.$inputName.on("change", () => this.userEditedAttribute()); - this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute()); this.$inputName.on("focus", () => { attributeAutocompleteService.initAttributeNameAutocomplete({ $el: this.$inputName, attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"), - open: true + open: true, + onValueChange: () => this.userEditedAttribute(), }); }); @@ -394,12 +395,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { } }); this.$inputValue.on("change", () => this.userEditedAttribute()); - this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute()); this.$inputValue.on("focus", () => { attributeAutocompleteService.initLabelValueAutocomplete({ $el: this.$inputValue, open: true, - nameCallback: () => String(this.$inputName.val()) + nameCallback: () => String(this.$inputName.val()), + onValueChange: () => this.userEditedAttribute(), }); }); @@ -480,7 +481,9 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget { this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes"); $(window).on("mousedown", (e) => { - if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) { + if (!$(e.target).closest(this.$widget[0]).length + && !$(e.target).closest(HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR).length + && !$(e.target).closest("#context-menu-container").length) { this.hide(); } }); diff --git a/apps/client/src/widgets/dialogs/add_link.spec.tsx b/apps/client/src/widgets/dialogs/add_link.spec.tsx new file mode 100644 index 00000000000..8c0c2c9b836 --- /dev/null +++ b/apps/client/src/widgets/dialogs/add_link.spec.tsx @@ -0,0 +1,70 @@ +import type { FunctionComponent } from "preact"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { type AddLinkDialogTestState,createAddLinkDialogTestState, setupAddLinkDialogMocks } from "./add_link.spec_utils"; + +describe("AddLinkDialog", () => { + let container: HTMLDivElement; + let AddLinkDialog: FunctionComponent; + let state: AddLinkDialogTestState; + + beforeEach(async () => { + vi.resetModules(); + state = createAddLinkDialogTestState(); + vi.clearAllMocks(); + setupAddLinkDialogMocks(state); + + ({ default: AddLinkDialog } = await import("./add_link")); + + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + act(() => { + render(null, container); + }); + container.remove(); + }); + + it("submits the selected note when Enter picks an autocomplete suggestion", async () => { + act(() => { + render(, container); + }); + + const showDialog = state.triliumEventHandlers.get("showAddLinkDialog"); + if (!showDialog) { + throw new Error("showAddLinkDialog handler was not registered"); + } + + await act(async () => { + showDialog({ + text: "", + hasSelection: false, + addLink: state.addLinkSpy + }); + }); + + act(() => { + state.latestNoteAutocompletePropsRef.current.onKeyDownCapture({ + key: "Enter", + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + isComposing: false + }); + state.latestNoteAutocompletePropsRef.current.onChange({ + notePath: "root/target-note" + }); + }); + + await act(async () => { + state.latestModalPropsRef.current.onHidden(); + }); + + expect(state.addLinkSpy).toHaveBeenCalledWith("root/target-note", null); + }); +}); diff --git a/apps/client/src/widgets/dialogs/add_link.spec_utils.tsx b/apps/client/src/widgets/dialogs/add_link.spec_utils.tsx new file mode 100644 index 00000000000..95f512e5565 --- /dev/null +++ b/apps/client/src/widgets/dialogs/add_link.spec_utils.tsx @@ -0,0 +1,99 @@ +import $ from "jquery"; +import type { ComponentChildren } from "preact"; +import { vi } from "vitest"; + +export interface AddLinkDialogTestState { + triliumEventHandlers: Map void>; + latestModalPropsRef: { current: any }; + latestNoteAutocompletePropsRef: { current: any }; + addLinkSpy: ReturnType; + logErrorSpy: ReturnType; + showRecentNotesSpy: ReturnType; + setTextSpy: ReturnType; +} + +export function createAddLinkDialogTestState(): AddLinkDialogTestState { + return { + triliumEventHandlers: new Map void>(), + latestModalPropsRef: { current: null as any }, + latestNoteAutocompletePropsRef: { current: null as any }, + addLinkSpy: vi.fn(() => Promise.resolve()), + logErrorSpy: vi.fn(), + showRecentNotesSpy: vi.fn(), + setTextSpy: vi.fn() + }; +} + +export function setupAddLinkDialogMocks(state: AddLinkDialogTestState) { + vi.doMock("../../services/i18n", () => ({ + t: (key: string) => key + })); + + vi.doMock("../../services/tree", () => ({ + default: { + getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1), + getNoteTitle: vi.fn(async () => "Target note") + } + })); + + vi.doMock("../../services/ws", () => ({ + logError: state.logErrorSpy + })); + + vi.doMock("../../services/note_autocomplete", () => ({ + __esModule: true, + default: { + showRecentNotes: state.showRecentNotesSpy, + setText: state.setTextSpy + } + })); + + vi.doMock("../react/react_utils", () => ({ + refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => ref.current ? $(ref.current) : $() + })); + + vi.doMock("../react/hooks", () => ({ + useTriliumEvent: (name: string, handler: (payload: any) => void) => { + state.triliumEventHandlers.set(name, handler); + } + })); + + vi.doMock("../react/Modal", () => ({ + default: (props: any) => { + state.latestModalPropsRef.current = props; + + if (!props.show) { + return null; + } + + return ( +
{ + e.preventDefault(); + props.onSubmit?.(); + }}> + {props.children} + {props.footer} +
+ ); + } + })); + + vi.doMock("../react/FormGroup", () => ({ + default: ({ children }: { children: ComponentChildren }) =>
{children}
+ })); + + vi.doMock("../react/Button", () => ({ + default: ({ text }: { text: string }) => + })); + + vi.doMock("../react/FormRadioGroup", () => ({ + default: () => null + })); + + vi.doMock("../react/NoteAutocomplete", () => ({ + default: (props: any) => { + state.latestNoteAutocompletePropsRef.current = props; + return ; + } + })); +} diff --git a/apps/client/src/widgets/dialogs/add_link.tsx b/apps/client/src/widgets/dialogs/add_link.tsx index 4bb1d1711ca..5d0ec6471c4 100644 --- a/apps/client/src/widgets/dialogs/add_link.tsx +++ b/apps/client/src/widgets/dialogs/add_link.tsx @@ -1,15 +1,17 @@ +import type { JSX } from "preact"; +import { useEffect,useRef, useState } from "preact/hooks"; + import { t } from "../../services/i18n"; -import Modal from "../react/Modal"; -import Button from "../react/Button"; -import FormRadioGroup from "../react/FormRadioGroup"; -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 tree from "../../services/tree"; import { logError } from "../../services/ws"; +import Button from "../react/Button"; import FormGroup from "../react/FormGroup.js"; -import { refToJQuerySelector } from "../react/react_utils"; +import FormRadioGroup from "../react/FormRadioGroup"; import { useTriliumEvent } from "../react/hooks"; +import Modal from "../react/Modal"; +import NoteAutocomplete from "../react/NoteAutocomplete"; +import { refToJQuerySelector } from "../react/react_utils"; type LinkType = "reference-link" | "external-link" | "hyper-link"; @@ -26,6 +28,8 @@ export default function AddLinkDialog() { const [ suggestion, setSuggestion ] = useState(null); const [ shown, setShown ] = useState(false); const hasSubmittedRef = useRef(false); + const suggestionRef = useRef(null); + const submitOnSelectionRef = useRef(false); useTriliumEvent("showAddLinkDialog", opts => { setOpts(opts); @@ -85,15 +89,44 @@ export default function AddLinkDialog() { .trigger("select"); } - function onSubmit() { - hasSubmittedRef.current = true; + function submitSelectedLink(selectedSuggestion: Suggestion | null) { + submitOnSelectionRef.current = false; + hasSubmittedRef.current = Boolean(selectedSuggestion); - if (suggestion) { - // Insertion logic in onHidden because it needs focus. - setShown(false); - } else { + if (!selectedSuggestion) { logError("No link to add."); + return; } + + // Insertion logic in onHidden because it needs focus. + setShown(false); + } + + function onSuggestionChange(nextSuggestion: Suggestion | null) { + suggestionRef.current = nextSuggestion; + setSuggestion(nextSuggestion); + + if (submitOnSelectionRef.current && nextSuggestion) { + submitSelectedLink(nextSuggestion); + } + } + + function onAutocompleteKeyDownCapture(e: JSX.TargetedKeyboardEvent) { + if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) { + return; + } + + submitOnSelectionRef.current = true; + } + + function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent) { + if (e.key === "Enter") { + submitOnSelectionRef.current = false; + } + } + + function onSubmit() { + submitSelectedLink(suggestionRef.current); } const autocompleteRef = useRef(null); @@ -109,19 +142,22 @@ export default function AddLinkDialog() { onSubmit={onSubmit} onShown={onShown} onHidden={() => { + submitOnSelectionRef.current = false; + // Insert the link. - if (hasSubmittedRef.current && suggestion && opts) { + if (hasSubmittedRef.current && suggestionRef.current && opts) { hasSubmittedRef.current = false; - if (suggestion.notePath) { + if (suggestionRef.current.notePath) { // Handle note link - opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle); - } else if (suggestion.externalLink) { + opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle); + } else if (suggestionRef.current.externalLink) { // Handle external link - opts.addLink(suggestion.externalLink, linkTitle, true); + opts.addLink(suggestionRef.current.externalLink, linkTitle, true); } } + suggestionRef.current = null; setSuggestion(null); setShown(false); }} @@ -130,7 +166,9 @@ export default function AddLinkDialog() { " : ""); const actualText = useRef(initialText); const [ shown, setShown ] = useState(false); - - async function openDialog(commandMode: boolean) { + + async function openDialog(commandMode: boolean) { let newMode: Mode; let initialText = ""; if (commandMode) { newMode = "commands"; - initialText = ">"; + initialText = ">"; } else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) { // if you open the Jump To dialog soon after using it previously, it can often mean that you // actually want to search for the same thing (e.g., you opened the wrong note at first try) @@ -58,7 +59,7 @@ export default function JumpToNoteDialogComponent() { if (!suggestion) { return; } - + setShown(false); if (suggestion.notePath) { appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath); @@ -83,7 +84,7 @@ export default function JumpToNoteDialogComponent() { $autoComplete .trigger("focus") .trigger("select"); - + // Add keyboard shortcut for full search shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => { if (!isCommandMode) { @@ -91,7 +92,7 @@ export default function JumpToNoteDialogComponent() { } }); } - + async function showInFullSearch() { try { setShown(false); @@ -126,18 +127,18 @@ export default function JumpToNoteDialogComponent() { setIsCommandMode(text.startsWith(">")); }} onChange={onItemSelected} - />} + />} onShown={onShown} onHidden={() => setShown(false)} - footer={!isCommandMode &&