mirror of
https://github.com/zadam/trilium.git
synced 2026-03-20 19:01:37 +01:00
Compare commits
44 Commits
experiment
...
autocomple
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65ddb08a0 | ||
|
|
22507f75bd | ||
|
|
915c49b472 | ||
|
|
aa58ad2812 | ||
|
|
17609799da | ||
|
|
f6201d8581 | ||
|
|
5b77152fdf | ||
|
|
b419602d74 | ||
|
|
2c9a0ed682 | ||
|
|
59ebd0f122 | ||
|
|
334c8cbea3 | ||
|
|
5adc79f867 | ||
|
|
d1fc4780b7 | ||
|
|
99937bd8f4 | ||
|
|
03a9685c96 | ||
|
|
39408f2b22 | ||
|
|
eeb917ea97 | ||
|
|
5ea355a587 | ||
|
|
e4ad356a02 | ||
|
|
ff939071ac | ||
|
|
3f97516d98 | ||
|
|
f06dd3cfea | ||
|
|
0dee06262b | ||
|
|
3ac2e2785d | ||
|
|
9869d29146 | ||
|
|
a1b51e1de8 | ||
|
|
cd7fb3d584 | ||
|
|
b92a5d1188 | ||
|
|
530e606ddb | ||
|
|
b6dea44460 | ||
|
|
a5445d35cb | ||
|
|
128fa63e7e | ||
|
|
8a4a06e656 | ||
|
|
0ca54396aa | ||
|
|
3a6606b9ac | ||
|
|
1614ccf6f6 | ||
|
|
27a7a157d5 | ||
|
|
d5b496e597 | ||
|
|
06f2aa1fd8 | ||
|
|
3328266cae | ||
|
|
6dd5352f40 | ||
|
|
eaf89c63a1 | ||
|
|
34ce5ebcbb | ||
|
|
c7980f42fe |
@@ -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",
|
||||
@@ -24,7 +25,6 @@
|
||||
"@fullcalendar/multimonth": "6.1.20",
|
||||
"@fullcalendar/rrule": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@lexical/react": "0.42.0",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.3",
|
||||
"@mermaid-js/layout-elk": "0.2.1",
|
||||
"@mind-elixir/node-menu": "5.0.1",
|
||||
@@ -36,16 +36,15 @@
|
||||
"@triliumnext/highlightjs": "workspace:*",
|
||||
"@triliumnext/share-theme": "workspace:*",
|
||||
"@triliumnext/split.js": "workspace:*",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.18.0",
|
||||
"@univerjs/preset-sheets-core": "0.18.0",
|
||||
"@univerjs/preset-sheets-data-validation": "0.18.0",
|
||||
"@univerjs/preset-sheets-filter": "0.18.0",
|
||||
"@univerjs/preset-sheets-find-replace": "0.18.0",
|
||||
"@univerjs/preset-sheets-note": "0.18.0",
|
||||
"@univerjs/preset-sheets-sort": "0.18.0",
|
||||
"@univerjs/presets": "0.18.0",
|
||||
"@univerjs/preset-sheets-conditional-formatting": "0.17.0",
|
||||
"@univerjs/preset-sheets-core": "0.17.0",
|
||||
"@univerjs/preset-sheets-data-validation": "0.17.0",
|
||||
"@univerjs/preset-sheets-filter": "0.17.0",
|
||||
"@univerjs/preset-sheets-find-replace": "0.17.0",
|
||||
"@univerjs/preset-sheets-note": "0.17.0",
|
||||
"@univerjs/preset-sheets-sort": "0.17.0",
|
||||
"@univerjs/presets": "0.17.0",
|
||||
"@zumer/snapdom": "2.5.0",
|
||||
"autocomplete.js": "0.38.1",
|
||||
"bootstrap": "5.3.8",
|
||||
"boxicons": "2.1.4",
|
||||
"clsx": "2.1.1",
|
||||
@@ -59,10 +58,10 @@
|
||||
"jquery": "4.0.0",
|
||||
"jquery.fancytree": "2.38.5",
|
||||
"jsplumb": "2.15.6",
|
||||
"katex": "0.16.39",
|
||||
"katex": "0.16.38",
|
||||
"knockout": "3.5.2",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-gpx": "2.2.0",
|
||||
"lexical": "0.42.0",
|
||||
"mark.js": "8.11.1",
|
||||
"marked": "17.0.4",
|
||||
"mermaid": "11.13.0",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 froca from "../services/froca.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 { 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 Mutex from "../utils/mutex.js";
|
||||
import type { EventData } from "./app_context.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();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import "autocomplete.js/index_jquery.js";
|
||||
|
||||
import type ElectronRemote from "@electron/remote";
|
||||
import type Electron from "electron";
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface MenuCommandItem<T> {
|
||||
title: string;
|
||||
command?: T;
|
||||
type?: string;
|
||||
mime?: string;
|
||||
/**
|
||||
* The icon to display in the menu item.
|
||||
*
|
||||
|
||||
@@ -288,7 +288,7 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
return items.filter((row) => row !== null) as MenuItem<TreeCommandNames>[];
|
||||
}
|
||||
|
||||
async selectMenuItemHandler({ command, type, mime, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||
async selectMenuItemHandler({ command, type, templateNoteId }: MenuCommandItem<TreeCommandNames>) {
|
||||
const notePath = treeService.getNotePath(this.node);
|
||||
|
||||
if (utils.isMobile()) {
|
||||
@@ -305,7 +305,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
target: "after",
|
||||
targetBranchId: this.node.data.branchId,
|
||||
type,
|
||||
mime,
|
||||
isProtected,
|
||||
templateNoteId
|
||||
});
|
||||
@@ -314,7 +313,6 @@ export default class TreeContextMenu implements SelectMenuItemEventListener<Tree
|
||||
|
||||
noteCreateService.createNote(parentNotePath, {
|
||||
type,
|
||||
mime,
|
||||
isProtected: this.node.data.isProtected,
|
||||
templateNoteId
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
47
apps/client/src/services/attribute_autocomplete.spec.ts
Normal file
47
apps/client/src/services/attribute_autocomplete.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<KeyboardEvent, "key" | "ctrlKey" | "metaKey">,
|
||||
{ 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 <input> element where the user types */
|
||||
$el: JQuery<HTMLElement>;
|
||||
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<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
|
||||
const result = names.map((name) => ({ name }));
|
||||
interface ManagedInstance {
|
||||
autocomplete: CoreAutocompleteApi<NameItem>;
|
||||
panelEl: HTMLElement;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
cb(result);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
|
||||
|
||||
$el.on("autocomplete:opened", () => {
|
||||
if ($el.attr("readonly")) {
|
||||
$el.autocomplete("close");
|
||||
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;
|
||||
}
|
||||
|
||||
onActivate(index);
|
||||
});
|
||||
}
|
||||
li.addEventListener("mouseleave", (event) => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (open) {
|
||||
$el.autocomplete("open");
|
||||
}
|
||||
onDeactivate();
|
||||
});
|
||||
li.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault(); // prevent input blur
|
||||
e.stopPropagation();
|
||||
onSelect(item);
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
panelEl.appendChild(list);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Attribute name autocomplete — new (autocomplete-core, headless)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let attributeName = "";
|
||||
if (nameCallback) {
|
||||
attributeName = nameCallback();
|
||||
}
|
||||
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
|
||||
const inputEl = $el[0] as HTMLInputElement;
|
||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
|
||||
if (attributeName.trim() === "") {
|
||||
// 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 attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
|
||||
const panelController = createHeadlessPanelController({ inputEl });
|
||||
const { panelEl } = panelController;
|
||||
|
||||
if (attributeValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
let isPanelOpen = false;
|
||||
let hasActiveItem = false;
|
||||
|
||||
$el.autocomplete(
|
||||
{
|
||||
appendTo: document.querySelector("body"),
|
||||
hint: false,
|
||||
openOnFocus: false, // handled manually
|
||||
minLength: 0,
|
||||
tabAutocomplete: false
|
||||
const autocomplete = createAutocomplete<NameItem>({
|
||||
openOnFocus: true,
|
||||
defaultActiveItemId: 0,
|
||||
shouldPanelOpen() {
|
||||
return true;
|
||||
},
|
||||
[
|
||||
{
|
||||
displayKey: "value",
|
||||
cache: false,
|
||||
source: async function (term, cb) {
|
||||
term = term.toLowerCase();
|
||||
|
||||
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
getSources({ query }) {
|
||||
return [
|
||||
withHeadlessSourceDefaults({
|
||||
sourceId: "attribute-names",
|
||||
getItems() {
|
||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
||||
return server
|
||||
.get<string[]>(`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);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
cb(filtered);
|
||||
}
|
||||
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();
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$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<NameItem>({
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label value autocomplete (headless autocomplete-core)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface LabelValueInitOptions {
|
||||
$el: JQuery<HTMLElement>;
|
||||
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<NameItem>) => {
|
||||
autocomplete.setQuery(inputEl.value || "");
|
||||
};
|
||||
|
||||
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;
|
||||
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<NameItem>({
|
||||
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<string[]>(`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);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
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;
|
||||
|
||||
if (state.isOpen && items.length > 0) {
|
||||
renderItems(
|
||||
panelEl,
|
||||
items,
|
||||
activeId,
|
||||
handleSelect,
|
||||
(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<NameItem>({
|
||||
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) {
|
||||
syncQueryFromInputValue(autocomplete);
|
||||
autocomplete.setIsOpen(true);
|
||||
autocomplete.refresh();
|
||||
panelController.startPositioning();
|
||||
}
|
||||
}
|
||||
|
||||
export function destroyAutocomplete($el: JQuery<HTMLElement> | 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,
|
||||
};
|
||||
|
||||
93
apps/client/src/services/autocomplete_core.spec.ts
Normal file
93
apps/client/src/services/autocomplete_core.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
195
apps/client/src/services/autocomplete_core.ts
Normal file
195
apps/client/src/services/autocomplete_core.ts
Normal file
@@ -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<Pick<AutocompleteSource<any>, "getItemUrl" | "onActive" | "onResolve">>;
|
||||
|
||||
const headlessAutocompleteClosers = new Set<() => void>();
|
||||
|
||||
export function withHeadlessSourceDefaults<TSource extends AutocompleteSource<any>>(
|
||||
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<TItem extends BaseItem> = ReturnType<AutocompleteApi<TItem>["getInputProps"]>;
|
||||
|
||||
interface InputBinding<TEvent extends Event = Event> {
|
||||
type: string;
|
||||
listener: (event: TEvent) => void;
|
||||
}
|
||||
|
||||
interface BindAutocompleteInputOptions<TItem extends BaseItem> {
|
||||
inputEl: HTMLInputElement;
|
||||
autocomplete: AutocompleteApi<TItem>;
|
||||
onInput?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onFocus?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onBlur?: (event: Event, handlers: InputHandlers<TItem>) => void;
|
||||
onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers<TItem>) => void;
|
||||
extraBindings?: InputBinding[];
|
||||
}
|
||||
|
||||
export function bindAutocompleteInput<TItem extends BaseItem>({
|
||||
inputEl,
|
||||
autocomplete,
|
||||
onInput,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
extraBindings = []
|
||||
}: BindAutocompleteInputOptions<TItem>) {
|
||||
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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "./focus.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";
|
||||
|
||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
||||
if (closeActDialog) {
|
||||
@@ -15,10 +17,7 @@ export async function openDialog($dialog: JQuery<HTMLElement>, 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();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import server from "./server.js";
|
||||
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 appContext from "../components/app_context.js";
|
||||
import type Component from "../components/component.js";
|
||||
import server from "./server.js";
|
||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||
|
||||
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
||||
|
||||
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
|
||||
@@ -51,7 +52,10 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, 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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,6 @@ export const NOTE_TYPES: NoteTypeMapping[] = [
|
||||
|
||||
// The default note type (always the first item)
|
||||
{ type: "text", mime: "text/html", title: t("note_types.text"), icon: "bx-note" },
|
||||
{ type: "text", mime: "application/json", title: "Text (Lexical)", icon: "bx-note" },
|
||||
{ type: "spreadsheet", mime: "application/json", title: t("note_types.spreadsheet"), icon: "bx-table", isBeta: true },
|
||||
|
||||
// Text notes group
|
||||
@@ -98,7 +97,6 @@ function getBlankNoteTypes(command?: TreeCommandNames): MenuItem<TreeCommandName
|
||||
title: nt.title,
|
||||
command,
|
||||
type: nt.type,
|
||||
mime: nt.mime,
|
||||
uiIcon: `bx ${nt.icon}`,
|
||||
badges: []
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type LabelType = "text" | "textarea" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
export type LabelType = "text" | "number" | "boolean" | "date" | "datetime" | "time" | "url" | "color";
|
||||
type Multiplicity = "single" | "multi";
|
||||
|
||||
export interface DefinitionObject {
|
||||
@@ -17,7 +17,7 @@ function parse(value: string) {
|
||||
for (const token of tokens) {
|
||||
if (token === "promoted") {
|
||||
defObj.isPromoted = true;
|
||||
} else if (["text", "textarea", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url", "color"].includes(token)) {
|
||||
defObj.labelType = token as LabelType;
|
||||
} else if (["single", "multi"].includes(token)) {
|
||||
defObj.multiplicity = token as Multiplicity;
|
||||
|
||||
@@ -1,107 +1,68 @@
|
||||
import "jquery";
|
||||
|
||||
import ko from "knockout";
|
||||
|
||||
import utils from "./services/utils.js";
|
||||
|
||||
type SetupStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop" | "sync-from-server";
|
||||
type SetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
// TriliumNextTODO: properly make use of below types
|
||||
// type SetupModelSetupType = "new-document" | "sync-from-desktop" | "sync-from-server" | "";
|
||||
// type SetupModelStep = "sync-in-progress" | "setup-type" | "new-document-in-progress" | "sync-from-desktop";
|
||||
|
||||
class SetupController {
|
||||
private step: SetupStep;
|
||||
private setupType: SetupType = "";
|
||||
private syncPollIntervalId: number | null = null;
|
||||
private rootNode: HTMLElement;
|
||||
private setupTypeForm: HTMLFormElement;
|
||||
private syncFromServerForm: HTMLFormElement;
|
||||
private setupTypeNextButton: HTMLButtonElement;
|
||||
private setupTypeInputs: HTMLInputElement[];
|
||||
private syncServerHostInput: HTMLInputElement;
|
||||
private syncProxyInput: HTMLInputElement;
|
||||
private passwordInput: HTMLInputElement;
|
||||
private sections: Record<SetupStep, HTMLElement>;
|
||||
class SetupModel {
|
||||
syncInProgress: boolean;
|
||||
step: ko.Observable<string>;
|
||||
setupType: ko.Observable<string>;
|
||||
setupNewDocument: ko.Observable<boolean>;
|
||||
setupSyncFromDesktop: ko.Observable<boolean>;
|
||||
setupSyncFromServer: ko.Observable<boolean>;
|
||||
syncServerHost: ko.Observable<string | undefined>;
|
||||
syncProxy: ko.Observable<string | undefined>;
|
||||
password: ko.Observable<string | undefined>;
|
||||
|
||||
constructor(rootNode: HTMLElement, syncInProgress: boolean) {
|
||||
this.rootNode = rootNode;
|
||||
this.step = syncInProgress ? "sync-in-progress" : "setup-type";
|
||||
this.setupTypeForm = mustGetElement("setup-type-form", HTMLFormElement);
|
||||
this.syncFromServerForm = mustGetElement("sync-from-server-form", HTMLFormElement);
|
||||
this.setupTypeNextButton = mustGetElement("setup-type-next", HTMLButtonElement);
|
||||
this.setupTypeInputs = Array.from(document.querySelectorAll<HTMLInputElement>("input[name='setup-type']"));
|
||||
this.syncServerHostInput = mustGetElement("sync-server-host", HTMLInputElement);
|
||||
this.syncProxyInput = mustGetElement("sync-proxy", HTMLInputElement);
|
||||
this.passwordInput = mustGetElement("password", HTMLInputElement);
|
||||
this.sections = {
|
||||
"setup-type": mustGetElement("setup-type-section", HTMLElement),
|
||||
"new-document-in-progress": mustGetElement("new-document-in-progress-section", HTMLElement),
|
||||
"sync-from-desktop": mustGetElement("sync-from-desktop-section", HTMLElement),
|
||||
"sync-from-server": mustGetElement("sync-from-server-section", HTMLElement),
|
||||
"sync-in-progress": mustGetElement("sync-in-progress-section", HTMLElement)
|
||||
};
|
||||
}
|
||||
constructor(syncInProgress: boolean) {
|
||||
this.syncInProgress = syncInProgress;
|
||||
this.step = ko.observable(syncInProgress ? "sync-in-progress" : "setup-type");
|
||||
this.setupType = ko.observable("");
|
||||
this.setupNewDocument = ko.observable(false);
|
||||
this.setupSyncFromDesktop = ko.observable(false);
|
||||
this.setupSyncFromServer = ko.observable(false);
|
||||
this.syncServerHost = ko.observable();
|
||||
this.syncProxy = ko.observable();
|
||||
this.password = ko.observable();
|
||||
|
||||
init() {
|
||||
this.setupTypeForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void this.selectSetupType();
|
||||
});
|
||||
|
||||
this.syncFromServerForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
void this.finish();
|
||||
});
|
||||
|
||||
for (const input of this.setupTypeInputs) {
|
||||
input.addEventListener("change", () => {
|
||||
this.setupType = input.value as SetupType;
|
||||
this.render();
|
||||
});
|
||||
if (this.syncInProgress) {
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
}
|
||||
|
||||
for (const backButton of document.querySelectorAll<HTMLElement>("[data-action='back']")) {
|
||||
backButton.addEventListener("click", () => {
|
||||
this.back();
|
||||
});
|
||||
}
|
||||
|
||||
const serverAddress = `${location.protocol}//${location.host}`;
|
||||
$("#current-host").html(serverAddress);
|
||||
|
||||
if (this.step === "sync-in-progress") {
|
||||
this.startSyncPolling();
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.rootNode.style.display = "";
|
||||
}
|
||||
|
||||
private async selectSetupType() {
|
||||
if (this.setupType === "new-document") {
|
||||
this.setStep("new-document-in-progress");
|
||||
// this is called in setup.ejs
|
||||
setupTypeSelected() {
|
||||
return !!this.setupType();
|
||||
}
|
||||
|
||||
await $.post("api/setup/new-document");
|
||||
window.location.replace("./setup");
|
||||
return;
|
||||
}
|
||||
selectSetupType() {
|
||||
if (this.setupType() === "new-document") {
|
||||
this.step("new-document-in-progress");
|
||||
|
||||
if (this.setupType) {
|
||||
this.setStep(this.setupType);
|
||||
$.post("api/setup/new-document").then(() => {
|
||||
window.location.replace("./setup");
|
||||
});
|
||||
} else {
|
||||
this.step(this.setupType());
|
||||
}
|
||||
}
|
||||
|
||||
private back() {
|
||||
this.setStep("setup-type");
|
||||
this.setupType = "";
|
||||
|
||||
for (const input of this.setupTypeInputs) {
|
||||
input.checked = false;
|
||||
}
|
||||
|
||||
this.render();
|
||||
back() {
|
||||
this.step("setup-type");
|
||||
this.setupType("");
|
||||
}
|
||||
|
||||
private async finish() {
|
||||
const syncServerHost = this.syncServerHostInput.value.trim();
|
||||
const syncProxy = this.syncProxyInput.value.trim();
|
||||
const password = this.passwordInput.value;
|
||||
async finish() {
|
||||
const syncServerHost = this.syncServerHost();
|
||||
const syncProxy = this.syncProxy();
|
||||
const password = this.password();
|
||||
|
||||
if (!syncServerHost) {
|
||||
showAlert("Trilium server address can't be empty");
|
||||
@@ -121,38 +82,15 @@ class SetupController {
|
||||
});
|
||||
|
||||
if (resp.result === "success") {
|
||||
this.step("sync-in-progress");
|
||||
|
||||
setInterval(checkOutstandingSyncs, 1000);
|
||||
|
||||
hideAlert();
|
||||
this.setStep("sync-in-progress");
|
||||
this.startSyncPolling();
|
||||
} else {
|
||||
showAlert(`Sync setup failed: ${resp.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private setStep(step: SetupStep) {
|
||||
this.step = step;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render() {
|
||||
for (const [step, section] of Object.entries(this.sections) as [SetupStep, HTMLElement][]) {
|
||||
section.style.display = step === this.step ? "" : "none";
|
||||
}
|
||||
|
||||
this.setupTypeNextButton.disabled = !this.setupType;
|
||||
}
|
||||
|
||||
private getSelectedSetupType(): SetupType {
|
||||
return (this.setupTypeInputs.find((input) => input.checked)?.value ?? "") as SetupType;
|
||||
}
|
||||
|
||||
private startSyncPolling() {
|
||||
if (this.syncPollIntervalId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncPollIntervalId = window.setInterval(checkOutstandingSyncs, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOutstandingSyncs() {
|
||||
@@ -186,19 +124,9 @@ function getSyncInProgress() {
|
||||
return !!parseInt(el.content);
|
||||
}
|
||||
|
||||
function mustGetElement<T extends typeof HTMLElement>(id: string, ctor: T): InstanceType<T> {
|
||||
const element = document.getElementById(id);
|
||||
|
||||
if (!element || !(element instanceof ctor)) {
|
||||
throw new Error(`Expected element #${id}`);
|
||||
}
|
||||
|
||||
return element as InstanceType<T>;
|
||||
}
|
||||
|
||||
addEventListener("DOMContentLoaded", (event) => {
|
||||
const rootNode = document.getElementById("setup-dialog");
|
||||
if (!rootNode || !(rootNode instanceof HTMLElement)) return;
|
||||
|
||||
new SetupController(rootNode, getSyncInProgress()).init();
|
||||
if (!rootNode) return;
|
||||
ko.applyBindings(new SetupModel(getSyncInProgress()), rootNode);
|
||||
$("#setup-dialog").show();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -675,11 +675,10 @@ li.dropdown-item a.dropdown-item-button:focus-visible {
|
||||
div.alert {
|
||||
margin-bottom: 8px;
|
||||
background: var(--alert-bar-background) !important;
|
||||
color: var(--main-text-color);
|
||||
border-radius: 8px;
|
||||
font-size: .85em;
|
||||
}
|
||||
|
||||
div.alert p + p {
|
||||
margin-block: 1em 0;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1069,6 +1069,7 @@
|
||||
"rename_note": "اعادة تسمية الملاحظة",
|
||||
"remove_relation": "حذف العلاقة",
|
||||
"default_new_note_title": "ملاحظة جديدة",
|
||||
"open_in_new_tab": "فتح في تبويب جديد",
|
||||
"enter_new_title": "ادخل عنوان ملاحظة جديدة:",
|
||||
"note_not_found": "الملاحظة {{noteId}} غير موجودة!",
|
||||
"cannot_match_transform": "تعذر مطابقة التحويل: {{transform}}"
|
||||
|
||||
@@ -1047,6 +1047,7 @@
|
||||
"unprotecting-title": "解除保护状态"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "在新标签页中打开",
|
||||
"remove_note": "删除笔记",
|
||||
"edit_title": "编辑标题",
|
||||
"rename_note": "重命名笔记",
|
||||
|
||||
@@ -1046,6 +1046,7 @@
|
||||
"unprotecting-title": "Ungeschützt-Status"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "In neuem Tab öffnen",
|
||||
"remove_note": "Notiz entfernen",
|
||||
"edit_title": "Titel bearbeiten",
|
||||
"rename_note": "Notiz umbenennen",
|
||||
|
||||
@@ -343,7 +343,6 @@
|
||||
"label_type_title": "Type of the label will help Trilium to choose suitable interface to enter the label value.",
|
||||
"label_type": "Type",
|
||||
"text": "Text",
|
||||
"textarea": "Multi-line Text",
|
||||
"number": "Number",
|
||||
"boolean": "Boolean",
|
||||
"date": "Date",
|
||||
@@ -1069,6 +1068,7 @@
|
||||
"unprotecting-title": "Unprotecting status"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Open in new tab",
|
||||
"remove_note": "Remove note",
|
||||
"edit_title": "Edit title",
|
||||
"rename_note": "Rename note",
|
||||
|
||||
@@ -1051,6 +1051,7 @@
|
||||
"unprotecting-title": "Estado de desprotección"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir en nueva pestaña",
|
||||
"remove_note": "Quitar nota",
|
||||
"edit_title": "Editar título",
|
||||
"rename_note": "Cambiar nombre de nota",
|
||||
|
||||
@@ -1036,6 +1036,7 @@
|
||||
"unprotecting-title": "Statut de la non-protection"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
|
||||
"remove_note": "Supprimer la note",
|
||||
"edit_title": "Modifier le titre",
|
||||
"rename_note": "Renommer la note",
|
||||
|
||||
@@ -1055,6 +1055,7 @@
|
||||
"unprotecting-title": "Stádas díchosanta"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Oscail i gcluaisín nua",
|
||||
"remove_note": "Bain nóta",
|
||||
"edit_title": "Cuir an teideal in eagar",
|
||||
"rename_note": "Athainmnigh an nóta",
|
||||
|
||||
@@ -1049,6 +1049,7 @@
|
||||
"unprotecting-title": "अन-प्रोटेक्ट स्टेटस"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "नए टैब में खोलें",
|
||||
"remove_note": "नोट हटाएं",
|
||||
"edit_title": "टाइटल एडिट करें",
|
||||
"rename_note": "नोट का नाम बदलें",
|
||||
|
||||
@@ -1424,6 +1424,7 @@
|
||||
"unprotecting-title": "Stato non protetto"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Apri in una nuova scheda",
|
||||
"remove_note": "Rimuovi nota",
|
||||
"edit_title": "Modifica titolo",
|
||||
"rename_note": "Rinomina nota",
|
||||
|
||||
@@ -1537,6 +1537,7 @@
|
||||
"url_placeholder": "http://web サイト..."
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "新しいタブで開く",
|
||||
"remove_note": "ノートを削除",
|
||||
"edit_title": "タイトルを編集",
|
||||
"rename_note": "ノート名を変更",
|
||||
|
||||
@@ -1275,6 +1275,7 @@
|
||||
"unprotecting-title": "Status zdejmowania ochrony"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Otwórz w nowej karcie",
|
||||
"remove_note": "Usuń notatkę",
|
||||
"edit_title": "Edytuj tytuł",
|
||||
"rename_note": "Zmień nazwę notatki",
|
||||
|
||||
@@ -1047,6 +1047,7 @@
|
||||
"unprotecting-title": "Estado da remoção de proteção"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir em nova guia",
|
||||
"remove_note": "Remover nota",
|
||||
"edit_title": "Editar título",
|
||||
"rename_note": "Renomear nota",
|
||||
|
||||
@@ -1111,6 +1111,7 @@
|
||||
"start_session_button": "Iniciar sessão protegida"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Abrir em nova aba",
|
||||
"remove_note": "Remover nota",
|
||||
"edit_title": "Editar título",
|
||||
"rename_note": "Renomear nota",
|
||||
|
||||
@@ -1054,6 +1054,7 @@
|
||||
"enter_title_of_new_note": "Introduceți titlul noii notițe",
|
||||
"note_already_in_diagram": "Notița „{{title}}” deja se află pe diagramă.",
|
||||
"note_not_found": "Notița „{{noteId}}” nu a putut fi găsită!",
|
||||
"open_in_new_tab": "Deschide într-un tab nou",
|
||||
"remove_note": "Șterge notița",
|
||||
"remove_relation": "Șterge relația",
|
||||
"rename_note": "Redenumește notița",
|
||||
|
||||
@@ -1625,6 +1625,7 @@
|
||||
"rename_note": "Переименовать заметку",
|
||||
"remove_relation": "Удалить отношение",
|
||||
"default_new_note_title": "новая заметка",
|
||||
"open_in_new_tab": "Открыть в новой вкладке",
|
||||
"confirm_remove_relation": "Вы уверены, что хотите удалить связь?",
|
||||
"enter_new_title": "Введите новое название заметки:",
|
||||
"note_not_found": "Заметка {{noteId}} не найдена!",
|
||||
|
||||
@@ -1046,6 +1046,7 @@
|
||||
"unprotecting-title": "解除保護狀態"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "在新分頁中打開",
|
||||
"remove_note": "刪除筆記",
|
||||
"edit_title": "編輯標題",
|
||||
"rename_note": "重新命名筆記",
|
||||
|
||||
@@ -1151,6 +1151,7 @@
|
||||
"unprotecting-title": "Статус зняття захисту"
|
||||
},
|
||||
"relation_map": {
|
||||
"open_in_new_tab": "Відкрити в новій вкладці",
|
||||
"remove_note": "Видалити нотатку",
|
||||
"edit_title": "Редагувати заголовок",
|
||||
"rename_note": "Перейменувати нотатку",
|
||||
|
||||
28
apps/client/src/types.d.ts
vendored
28
apps/client/src/types.d.ts
vendored
@@ -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<HTMLElement>;
|
||||
|
||||
getSelectedNotePath(): string | undefined;
|
||||
getSelectedNoteId(): string | null;
|
||||
setSelectedNotePath(notePath: string | null | undefined);
|
||||
|
||||
@@ -2,12 +2,13 @@ import "./PromotedAttributes.css";
|
||||
|
||||
import { UpdateAttributeResponse } from "@triliumnext/commons";
|
||||
import clsx from "clsx";
|
||||
import { ComponentChild, createElement, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
|
||||
import { ComponentChild, HTMLInputTypeAttribute, InputHTMLAttributes, MouseEventHandler, TargetedEvent, TargetedInputEvent } from "preact";
|
||||
import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||
|
||||
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<HTMLInputElement | HTMLTextAreaElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
|
||||
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
|
||||
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent;
|
||||
|
||||
export default function PromotedAttributes() {
|
||||
const { note, componentId, noteContext } = useNoteContext();
|
||||
@@ -171,9 +171,8 @@ function PromotedAttributeCell(props: CellProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const LABEL_MAPPINGS: Record<LabelType, HTMLInputTypeAttribute | undefined> = {
|
||||
const LABEL_MAPPINGS: Record<LabelType, HTMLInputTypeAttribute> = {
|
||||
text: "text",
|
||||
textarea: undefined,
|
||||
number: "number",
|
||||
boolean: "checkbox",
|
||||
date: "date",
|
||||
@@ -201,11 +200,7 @@ 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, setDraft);
|
||||
|
||||
// React to model changes.
|
||||
useEffect(() => {
|
||||
@@ -227,21 +222,20 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const inputNode = createElement(definition.labelType === "textarea" ? "textarea" : "input", {
|
||||
className: "form-control promoted-attribute-input",
|
||||
tabIndex: 200 + definitionAttr.position,
|
||||
id: inputId,
|
||||
type: LABEL_MAPPINGS[definition.labelType ?? "text"],
|
||||
value: valueDraft,
|
||||
checked: definition.labelType === "boolean" ? valueAttr.value === "true" : undefined,
|
||||
placeholder: t("promoted_attributes.unset-field-placeholder"),
|
||||
"data-attribute-id": valueAttr.attributeId,
|
||||
"data-attribute-type": valueAttr.type,
|
||||
"data-attribute-name": valueAttr.name,
|
||||
onBlur: onChangeListener,
|
||||
...extraInputProps
|
||||
});
|
||||
const inputNode = <input
|
||||
className="form-control promoted-attribute-input"
|
||||
tabIndex={200 + definitionAttr.position}
|
||||
id={inputId}
|
||||
type={LABEL_MAPPINGS[definition.labelType ?? "text"]}
|
||||
value={valueDraft}
|
||||
checked={definition.labelType === "boolean" ? valueAttr.value === "true" : undefined}
|
||||
placeholder={t("promoted_attributes.unset-field-placeholder")}
|
||||
data-attribute-id={valueAttr.attributeId}
|
||||
data-attribute-type={valueAttr.type}
|
||||
data-attribute-name={valueAttr.name}
|
||||
onBlur={onChangeListener}
|
||||
{...extraInputProps}
|
||||
/>;
|
||||
|
||||
if (definition.labelType === "boolean") {
|
||||
return <>
|
||||
@@ -260,7 +254,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 +409,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<string[]>(`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();
|
||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||
$el: $input,
|
||||
open: false,
|
||||
nameCallback: () => valueAttr.name,
|
||||
onValueChange: (value) => {
|
||||
onValueChange(value);
|
||||
}
|
||||
});
|
||||
|
||||
const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term));
|
||||
|
||||
cb(filtered);
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
$input.off("autocomplete:selected");
|
||||
$input.on("autocomplete:selected", onChangeListener);
|
||||
|
||||
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<StateUpdater<Cell[] | undefined>>) {
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { t } from "../../services/i18n.js";
|
||||
import server from "../../services/server.js";
|
||||
import froca from "../../services/froca.js";
|
||||
import linkService from "../../services/link.js";
|
||||
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";
|
||||
import { t } from "../../services/i18n.js";
|
||||
import linkService from "../../services/link.js";
|
||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
import server from "../../services/server.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import SpacedUpdate from "../../services/spaced_update.js";
|
||||
import utils from "../../services/utils.js";
|
||||
import shortcutService from "../../services/shortcuts.js";
|
||||
import appContext from "../../components/app_context.js";
|
||||
import type { Attribute } from "../../services/attribute_parser.js";
|
||||
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
|
||||
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
|
||||
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||
|
||||
const TPL = /*html*/`
|
||||
<div class="attr-detail tn-tool-dialog">
|
||||
@@ -137,7 +138,6 @@ const TPL = /*html*/`
|
||||
<td>
|
||||
<select class="attr-input-label-type form-control">
|
||||
<option value="text">${t("attribute_detail.text")}</option>
|
||||
<option value="textarea">${t("attribute_detail.textarea")}</option>
|
||||
<option value="number">${t("attribute_detail.number")}</option>
|
||||
<option value="boolean">${t("attribute_detail.boolean")}</option>
|
||||
<option value="date">${t("attribute_detail.date")}</option>
|
||||
@@ -373,13 +373,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(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -392,12 +392,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(),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -478,7 +478,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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19,13 +19,6 @@ const labelTypeMappings: Record<ColumnType, Partial<ColumnDefinition>> = {
|
||||
text: {
|
||||
editor: "input"
|
||||
},
|
||||
textarea: {
|
||||
editor: "textarea",
|
||||
formatter: "textarea",
|
||||
editorParams: {
|
||||
shiftEnterSubmit: true
|
||||
}
|
||||
},
|
||||
boolean: {
|
||||
formatter: "tickCross",
|
||||
editor: "tickCross"
|
||||
|
||||
@@ -75,9 +75,3 @@
|
||||
font-size: 1.5em;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.tabulator .tabulator-editable {
|
||||
textarea {
|
||||
padding: 7px !important;
|
||||
}
|
||||
}
|
||||
|
||||
160
apps/client/src/widgets/dialogs/add_link.spec.tsx
Normal file
160
apps/client/src/widgets/dialogs/add_link.spec.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import $ from "jquery";
|
||||
import type { ComponentChildren } from "preact";
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
triliumEventHandlers,
|
||||
latestModalPropsRef,
|
||||
latestNoteAutocompletePropsRef,
|
||||
addLinkSpy,
|
||||
logErrorSpy,
|
||||
showRecentNotesSpy,
|
||||
setTextSpy
|
||||
} = vi.hoisted(() => ({
|
||||
triliumEventHandlers: new Map<string, (payload: any) => 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()
|
||||
}));
|
||||
|
||||
vi.mock("../../services/i18n", () => ({
|
||||
t: (key: string) => key
|
||||
}));
|
||||
|
||||
vi.mock("../../services/tree", () => ({
|
||||
default: {
|
||||
getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1),
|
||||
getNoteTitle: vi.fn(async () => "Target note")
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../../services/ws", () => ({
|
||||
logError: logErrorSpy
|
||||
}));
|
||||
|
||||
vi.mock("../../services/note_autocomplete", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
showRecentNotes: showRecentNotesSpy,
|
||||
setText: setTextSpy
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../react/react_utils", () => ({
|
||||
refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => ref.current ? $(ref.current) : $()
|
||||
}));
|
||||
|
||||
vi.mock("../react/hooks", () => ({
|
||||
useTriliumEvent: (name: string, handler: (payload: any) => void) => {
|
||||
triliumEventHandlers.set(name, handler);
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../react/Modal", () => ({
|
||||
default: (props: any) => {
|
||||
latestModalPropsRef.current = props;
|
||||
|
||||
if (!props.show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
props.onSubmit?.();
|
||||
}}>
|
||||
{props.children}
|
||||
{props.footer}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock("../react/FormGroup", () => ({
|
||||
default: ({ children }: { children: ComponentChildren }) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
vi.mock("../react/Button", () => ({
|
||||
default: ({ text }: { text: string }) => <button type="submit">{text}</button>
|
||||
}));
|
||||
|
||||
vi.mock("../react/FormRadioGroup", () => ({
|
||||
default: () => null
|
||||
}));
|
||||
|
||||
vi.mock("../react/NoteAutocomplete", () => ({
|
||||
default: (props: any) => {
|
||||
latestNoteAutocompletePropsRef.current = props;
|
||||
return <input ref={props.inputRef} />;
|
||||
}
|
||||
}));
|
||||
|
||||
import AddLinkDialog from "./add_link";
|
||||
|
||||
describe("AddLinkDialog", () => {
|
||||
let container: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
latestModalPropsRef.current = null;
|
||||
latestNoteAutocompletePropsRef.current = null;
|
||||
triliumEventHandlers.clear();
|
||||
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(<AddLinkDialog />, container);
|
||||
});
|
||||
|
||||
const showDialog = triliumEventHandlers.get("showAddLinkDialog");
|
||||
expect(showDialog).toBeTypeOf("function");
|
||||
|
||||
await act(async () => {
|
||||
showDialog?.({
|
||||
text: "",
|
||||
hasSelection: false,
|
||||
addLink: addLinkSpy
|
||||
});
|
||||
});
|
||||
|
||||
const suggestion = {
|
||||
notePath: "root/target-note",
|
||||
noteTitle: "Target note"
|
||||
};
|
||||
|
||||
act(() => {
|
||||
latestNoteAutocompletePropsRef.current.onKeyDownCapture({
|
||||
key: "Enter",
|
||||
ctrlKey: false,
|
||||
metaKey: false,
|
||||
shiftKey: false,
|
||||
altKey: false,
|
||||
isComposing: false
|
||||
});
|
||||
latestNoteAutocompletePropsRef.current.onChange(suggestion);
|
||||
});
|
||||
|
||||
expect(latestModalPropsRef.current.show).toBe(false);
|
||||
expect(logErrorSpy).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
latestModalPropsRef.current.onHidden();
|
||||
});
|
||||
|
||||
expect(addLinkSpy).toHaveBeenCalledWith("root/target-note", null);
|
||||
});
|
||||
});
|
||||
@@ -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<Suggestion | null>(null);
|
||||
const [ shown, setShown ] = useState(false);
|
||||
const hasSubmittedRef = useRef(false);
|
||||
const suggestionRef = useRef<Suggestion | null>(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<HTMLInputElement>) {
|
||||
if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitOnSelectionRef.current = true;
|
||||
}
|
||||
|
||||
function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
submitOnSelectionRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
submitSelectedLink(suggestionRef.current);
|
||||
}
|
||||
|
||||
const autocompleteRef = useRef<HTMLInputElement>(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() {
|
||||
<FormGroup label={t("add_link.note")} name="note">
|
||||
<NoteAutocomplete
|
||||
inputRef={autocompleteRef}
|
||||
onChange={setSuggestion}
|
||||
onChange={onSuggestionChange}
|
||||
onKeyDownCapture={onAutocompleteKeyDownCapture}
|
||||
onKeyUpCapture={onAutocompleteKeyUpCapture}
|
||||
opts={{
|
||||
allowExternalLinks: true,
|
||||
allowCreatingNotes: true
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import Modal from "../react/Modal";
|
||||
import Button from "../react/Button";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { t } from "../../services/i18n";
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
|
||||
import appContext from "../../components/app_context";
|
||||
import commandRegistry from "../../services/command_registry";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||
import shortcutService from "../../services/shortcuts";
|
||||
import Button from "../react/Button";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
|
||||
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
||||
|
||||
@@ -23,14 +24,14 @@ export default function JumpToNoteDialogComponent() {
|
||||
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
|
||||
const actualText = useRef<string>(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 && <Button
|
||||
className="show-in-full-text-button"
|
||||
text={t("jump_to_note.search_button")}
|
||||
footer={!isCommandMode && <Button
|
||||
className="show-in-full-text-button"
|
||||
text={t("jump_to_note.search_button")}
|
||||
keyboardShortcut="Ctrl+Enter"
|
||||
onClick={showInFullSearch}
|
||||
/>}
|
||||
show={shown}
|
||||
>
|
||||
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
|
||||
<div className="jump-to-note-results" ref={containerRef} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useRef, useState } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import Button from "../react/Button";
|
||||
import Modal from "../react/Modal";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import FormGroup from "../react/FormGroup";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
import FormTextBox from "../react/FormTextBox";
|
||||
import { useTriliumEvent } from "../react/hooks";
|
||||
import Modal from "../react/Modal";
|
||||
import { refToJQuerySelector } from "../react/react_utils";
|
||||
|
||||
// JQuery here is maintained for compatibility with existing code.
|
||||
interface ShownCallbackData {
|
||||
@@ -40,7 +41,7 @@ export default function PromptDialog() {
|
||||
opts.current = newOpts;
|
||||
setValue(newOpts.defaultValue ?? "");
|
||||
setShown(true);
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -60,7 +61,7 @@ export default function PromptDialog() {
|
||||
answerRef.current?.select();
|
||||
}}
|
||||
onSubmit={() => {
|
||||
submitValue.current = value;
|
||||
submitValue.current = answerRef.current?.value || value;
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => {
|
||||
|
||||
135
apps/client/src/widgets/react/NoteAutocomplete.spec.tsx
Normal file
135
apps/client/src/widgets/react/NoteAutocomplete.spec.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import $ from "jquery";
|
||||
import { render } from "preact";
|
||||
import { act } from "preact/test-utils";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
initNoteAutocompleteSpy,
|
||||
setTextSpy,
|
||||
clearTextSpy
|
||||
} = vi.hoisted(() => ({
|
||||
initNoteAutocompleteSpy: vi.fn(($el) => $el),
|
||||
setTextSpy: vi.fn(),
|
||||
clearTextSpy: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("../../services/i18n", () => ({
|
||||
t: (key: string) => key
|
||||
}));
|
||||
|
||||
vi.mock("../../services/note_autocomplete", () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
initNoteAutocomplete: initNoteAutocompleteSpy,
|
||||
setText: setTextSpy,
|
||||
clearText: clearTextSpy
|
||||
}
|
||||
}));
|
||||
|
||||
import NoteAutocomplete from "./NoteAutocomplete";
|
||||
|
||||
describe("NoteAutocomplete", () => {
|
||||
let container: HTMLDivElement;
|
||||
let setNoteSpy: ReturnType<typeof vi.fn>;
|
||||
let getSelectedNoteIdSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
|
||||
setNoteSpy = vi.fn(() => Promise.resolve());
|
||||
getSelectedNoteIdSpy = vi.fn(() => "selected-note-id");
|
||||
|
||||
($.fn as any).setNote = setNoteSpy;
|
||||
($.fn as any).getSelectedNoteId = getSelectedNoteIdSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
render(null, container);
|
||||
});
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it("syncs text props through the headless helper functions", () => {
|
||||
act(() => {
|
||||
render(<NoteAutocomplete text="hello" />, container);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
|
||||
expect(initNoteAutocompleteSpy).toHaveBeenCalledTimes(1);
|
||||
expect(initNoteAutocompleteSpy.mock.calls[0][0][0]).toBe(input);
|
||||
expect(setTextSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setTextSpy.mock.calls[0][0][0]).toBe(input);
|
||||
expect(setTextSpy).toHaveBeenCalledWith(expect.anything(), "hello");
|
||||
|
||||
act(() => {
|
||||
render(<NoteAutocomplete text="" />, container);
|
||||
});
|
||||
|
||||
expect(clearTextSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("syncs noteId props through the jQuery setNote extension", () => {
|
||||
act(() => {
|
||||
render(<NoteAutocomplete noteId="note-123" />, container);
|
||||
});
|
||||
|
||||
expect(setNoteSpy).toHaveBeenCalledWith("note-123");
|
||||
expect(clearTextSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards autocomplete selection and clear events to consumers", () => {
|
||||
const onChange = vi.fn();
|
||||
const noteIdChanged = vi.fn();
|
||||
|
||||
act(() => {
|
||||
render(<NoteAutocomplete onChange={onChange} noteIdChanged={noteIdChanged} />, container);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
const $input = $(input);
|
||||
const suggestion = { notePath: "root/child-note", noteTitle: "Child note" };
|
||||
|
||||
$input.trigger("autocomplete:noteselected", [suggestion]);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(suggestion);
|
||||
expect(noteIdChanged).toHaveBeenCalledWith("child-note");
|
||||
|
||||
input.value = "";
|
||||
$input.trigger("change");
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("forwards onTextChange, onKeyDown and onBlur events", () => {
|
||||
const onTextChange = vi.fn();
|
||||
const onKeyDown = vi.fn();
|
||||
const onBlur = vi.fn();
|
||||
|
||||
act(() => {
|
||||
render(
|
||||
<NoteAutocomplete
|
||||
onTextChange={onTextChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onBlur={onBlur}
|
||||
/>,
|
||||
container
|
||||
);
|
||||
});
|
||||
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
const $input = $(input);
|
||||
|
||||
input.value = "typed text";
|
||||
$input.trigger("input");
|
||||
$input.trigger($.Event("keydown", { originalEvent: new KeyboardEvent("keydown", { key: "Enter" }) }));
|
||||
$input.trigger("blur");
|
||||
|
||||
expect(onTextChange).toHaveBeenCalledWith("typed text");
|
||||
expect(onKeyDown).toHaveBeenCalledWith(expect.any(KeyboardEvent));
|
||||
expect(onBlur).toHaveBeenCalledWith("selected-note-id");
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { t } from "../../services/i18n";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import type { RefObject } from "preact";
|
||||
import type { JSX,RefObject } from "preact";
|
||||
import type { CSSProperties } from "preact/compat";
|
||||
import { useEffect } from "preact/hooks";
|
||||
|
||||
import { t } from "../../services/i18n";
|
||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||
import { useSyncedRef } from "./hooks";
|
||||
|
||||
interface NoteAutocompleteProps {
|
||||
@@ -16,85 +17,111 @@ interface NoteAutocompleteProps {
|
||||
onChange?: (suggestion: Suggestion | null) => void;
|
||||
onTextChange?: (text: string) => void;
|
||||
onKeyDown?: (e: KeyboardEvent) => void;
|
||||
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
||||
onBlur?: (newValue: string) => void;
|
||||
noteIdChanged?: (noteId: string) => void;
|
||||
noteId?: string;
|
||||
}
|
||||
|
||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) {
|
||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) {
|
||||
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
|
||||
// clear any event listener added in previous invocation of this function
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected")
|
||||
.off("autocomplete:commandselected")
|
||||
|
||||
note_autocomplete.initNoteAutocomplete($autoComplete, {
|
||||
...opts,
|
||||
container: container?.current
|
||||
});
|
||||
if (onTextChange) {
|
||||
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
|
||||
}
|
||||
if (onKeyDown) {
|
||||
$autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
|
||||
}
|
||||
}, [opts, container?.current]);
|
||||
|
||||
// On change event handlers.
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
const inputListener = () => onTextChange?.($autoComplete[0].value);
|
||||
const keyDownListener = (e) => e.originalEvent && onKeyDown?.(e.originalEvent);
|
||||
const blurListener = () => onBlur?.($autoComplete.getSelectedNoteId() ?? "");
|
||||
|
||||
if (onChange || noteIdChanged) {
|
||||
const autoCompleteListener = (_e, suggestion) => {
|
||||
onChange?.(suggestion);
|
||||
|
||||
if (noteIdChanged) {
|
||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||
noteIdChanged(noteId);
|
||||
}
|
||||
};
|
||||
const changeListener = (e) => {
|
||||
if (!ref.current?.value) {
|
||||
autoCompleteListener(e, null);
|
||||
}
|
||||
};
|
||||
$autoComplete
|
||||
.on("autocomplete:noteselected", autoCompleteListener)
|
||||
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.on("autocomplete:commandselected", autoCompleteListener)
|
||||
.on("change", changeListener);
|
||||
return () => {
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected", autoCompleteListener)
|
||||
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.off("autocomplete:commandselected", autoCompleteListener)
|
||||
.off("change", changeListener);
|
||||
};
|
||||
if (onTextChange) {
|
||||
$autoComplete.on("input", inputListener);
|
||||
}
|
||||
}, [opts, container?.current, onChange, noteIdChanged])
|
||||
if (onKeyDown) {
|
||||
$autoComplete.on("keydown", keyDownListener);
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.on("blur", blurListener);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (onTextChange) {
|
||||
$autoComplete.off("input", inputListener);
|
||||
}
|
||||
if (onKeyDown) {
|
||||
$autoComplete.off("keydown", keyDownListener);
|
||||
}
|
||||
if (onBlur) {
|
||||
$autoComplete.off("blur", blurListener);
|
||||
}
|
||||
};
|
||||
}, [onBlur, onKeyDown, onTextChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
if (!(onChange || noteIdChanged)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const autoCompleteListener = (_e, suggestion) => {
|
||||
onChange?.(suggestion);
|
||||
|
||||
if (noteIdChanged) {
|
||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||
noteIdChanged(noteId);
|
||||
}
|
||||
};
|
||||
const changeListener = (e) => {
|
||||
if (!ref.current?.value) {
|
||||
autoCompleteListener(e, null);
|
||||
}
|
||||
};
|
||||
|
||||
$autoComplete
|
||||
.on("autocomplete:noteselected", autoCompleteListener)
|
||||
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.on("autocomplete:commandselected", autoCompleteListener)
|
||||
.on("change", changeListener);
|
||||
|
||||
return () => {
|
||||
$autoComplete
|
||||
.off("autocomplete:noteselected", autoCompleteListener)
|
||||
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||
.off("autocomplete:commandselected", autoCompleteListener)
|
||||
.off("change", changeListener);
|
||||
};
|
||||
}, [onChange, noteIdChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const $autoComplete = $(ref.current);
|
||||
|
||||
if (noteId) {
|
||||
$autoComplete.setNote(noteId);
|
||||
} else if (text) {
|
||||
note_autocomplete.setText($autoComplete, text);
|
||||
} else {
|
||||
$autoComplete.setSelectedNotePath("");
|
||||
$autoComplete.autocomplete("val", "");
|
||||
ref.current.value = "";
|
||||
void $autoComplete.setNote(noteId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (text !== undefined) {
|
||||
if (text) {
|
||||
note_autocomplete.setText($autoComplete, text);
|
||||
} else {
|
||||
note_autocomplete.clearText($autoComplete);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
note_autocomplete.clearText($autoComplete);
|
||||
}, [text, noteId]);
|
||||
|
||||
return (
|
||||
@@ -103,6 +130,8 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
|
||||
id={id}
|
||||
ref={ref}
|
||||
className="note-autocomplete form-control"
|
||||
onKeyDownCapture={onKeyDownCapture}
|
||||
onKeyUpCapture={onKeyUpCapture}
|
||||
placeholder={placeholder ?? t("add_link.search_note")} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -50,9 +50,8 @@ body.desktop {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.note-detail-empty-results .aa-dropdown-menu {
|
||||
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||
border-top: 0;
|
||||
.note-detail-empty-results .aa-core-panel--contained {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.empty-tab-search label {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Connection } from "jsplumb";
|
||||
import { RefObject } from "preact";
|
||||
|
||||
import appContext from "../../../components/app_context";
|
||||
import FNote from "../../../entities/fnote";
|
||||
import contextMenu from "../../../menus/context_menu";
|
||||
import link_context_menu from "../../../menus/link_context_menu";
|
||||
import dialog from "../../../services/dialog";
|
||||
import { t } from "../../../services/i18n";
|
||||
import server from "../../../services/server";
|
||||
import RelationMapApi from "./api";
|
||||
import { Connection } from "jsplumb";
|
||||
|
||||
export function buildNoteContextMenuHandler(note: FNote | null | undefined, mapApiRef: RefObject<RelationMapApi>) {
|
||||
return (e: MouseEvent) => {
|
||||
@@ -19,8 +17,22 @@ export function buildNoteContextMenuHandler(note: FNote | null | undefined, mapA
|
||||
x: e.pageX,
|
||||
y: e.pageY,
|
||||
items: [
|
||||
...link_context_menu.getItems(e),
|
||||
{ kind: "separator" },
|
||||
{
|
||||
title: t("relation_map.open_in_new_tab"),
|
||||
uiIcon: "bx bx-empty",
|
||||
handler: () => appContext.tabManager.openTabWithNoteWithHoisting(note.noteId)
|
||||
},
|
||||
{
|
||||
title: t("relation_map.remove_note"),
|
||||
uiIcon: "bx bx-trash",
|
||||
handler: async () => {
|
||||
if (!note) return;
|
||||
const result = await dialog.confirmDeleteNoteBoxWithNote(note.title);
|
||||
if (typeof result !== "object" || !result.confirmed) return;
|
||||
|
||||
mapApiRef.current?.removeItem(note.noteId, result.isDeleteNoteChecked);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: t("relation_map.edit_title"),
|
||||
uiIcon: "bx bx-pencil",
|
||||
@@ -37,26 +49,10 @@ export function buildNoteContextMenuHandler(note: FNote | null | undefined, mapA
|
||||
|
||||
await server.put(`notes/${note.noteId}/title`, { title });
|
||||
}
|
||||
},
|
||||
{ kind: "separator" },
|
||||
|
||||
{
|
||||
title: t("relation_map.remove_note"),
|
||||
uiIcon: "bx bx-trash",
|
||||
handler: async () => {
|
||||
if (!note) return;
|
||||
const result = await dialog.confirmDeleteNoteBoxWithNote(note.title);
|
||||
if (typeof result !== "object" || !result.confirmed) return;
|
||||
|
||||
mapApiRef.current?.removeItem(note.noteId, result.isDeleteNoteChecked);
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
selectMenuItemHandler({ command }) {
|
||||
// Pass the events to the link context menu
|
||||
link_context_menu.handleLinkContextMenuItem(command, e, note.noteId);
|
||||
}
|
||||
});
|
||||
selectMenuItemHandler() {}
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,10 @@ import note_create from "../../../services/note_create";
|
||||
import options from "../../../services/options";
|
||||
import toast from "../../../services/toast";
|
||||
import utils, { hasTouchBar, isMobile } from "../../../services/utils";
|
||||
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useNoteProperty, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import { useEditorSpacedUpdate, useLegacyImperativeHandlers, useNoteLabel, useTriliumEvent, useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
|
||||
import TouchBar, { TouchBarButton, TouchBarGroup, TouchBarSegmentedControl } from "../../react/TouchBar";
|
||||
import { TypeWidgetProps } from "../type_widget";
|
||||
import CKEditorWithWatchdog, { CKEditorApi } from "./CKEditorWithWatchdog";
|
||||
import LexicalText from "./lexical";
|
||||
import getTemplates, { updateTemplateCache } from "./snippets.js";
|
||||
import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./utils";
|
||||
|
||||
@@ -28,15 +27,7 @@ import { loadIncludedNote, refreshIncludedNote, setupImageOpening } from "./util
|
||||
* - Ballon block mode, in which there is a floating toolbar for the selected text, but another floating button for the entire block (i.e. paragraph).
|
||||
* - Decoupled mode, in which the editing toolbar is actually added on the client side (in {@link ClassicEditorToolbar}), see https://ckeditor.com/docs/ckeditor5/latest/examples/framework/bottom-toolbar-editor.html for an example on how the decoupled editor works.
|
||||
*/
|
||||
export default function EditableText(props: TypeWidgetProps) {
|
||||
const mime = useNoteProperty(props.note, "mime");
|
||||
if (mime === "application/json") {
|
||||
return <LexicalText {...props} />;
|
||||
}
|
||||
return <EditableTextCKEditor {...props} />;
|
||||
}
|
||||
|
||||
function EditableTextCKEditor({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
|
||||
export default function EditableText({ note, parentComponent, ntxId, noteContext }: TypeWidgetProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<string>("");
|
||||
const watchdogRef = useRef<EditorWatchdog>(null);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
.note-detail-editable-text {
|
||||
.toolbar {
|
||||
display: flex;
|
||||
margin-bottom: 1px;
|
||||
background: var(--classic-toolbar-vert-layout-background-color);
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
margin: 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.toolbar .divider {
|
||||
width: 1px;
|
||||
background-color: var(--main-border-color);
|
||||
margin: 0 6px;
|
||||
}
|
||||
|
||||
.toolbar .toolbar-item .text {
|
||||
display: flex;
|
||||
line-height: 20px;
|
||||
width: 200px;
|
||||
vertical-align: middle;
|
||||
font-size: 14px;
|
||||
color: #777;
|
||||
text-overflow: ellipsis;
|
||||
width: 70px;
|
||||
overflow: hidden;
|
||||
height: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toolbar .toolbar-item .icon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
user-select: none;
|
||||
margin-right: 8px;
|
||||
line-height: 16px;
|
||||
background-size: contain;
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
*/
|
||||
import "./ToolbarPlugin.css";
|
||||
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {mergeRegister} from '@lexical/utils';
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
CAN_REDO_COMMAND,
|
||||
CAN_UNDO_COMMAND,
|
||||
COMMAND_PRIORITY_LOW,
|
||||
FORMAT_ELEMENT_COMMAND,
|
||||
FORMAT_TEXT_COMMAND,
|
||||
REDO_COMMAND,
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
UNDO_COMMAND,
|
||||
} from 'lexical';
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
|
||||
import ActionButton, { ActionButtonProps } from "../../../react/ActionButton";
|
||||
|
||||
function Divider() {
|
||||
return <div className="divider" />;
|
||||
}
|
||||
|
||||
export default function ToolbarPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const toolbarRef = useRef(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
const [isBold, setIsBold] = useState(false);
|
||||
const [isItalic, setIsItalic] = useState(false);
|
||||
const [isUnderline, setIsUnderline] = useState(false);
|
||||
const [isStrikethrough, setIsStrikethrough] = useState(false);
|
||||
|
||||
const $updateToolbar = useCallback(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
// Update text format
|
||||
setIsBold(selection.hasFormat('bold'));
|
||||
setIsItalic(selection.hasFormat('italic'));
|
||||
setIsUnderline(selection.hasFormat('underline'));
|
||||
setIsStrikethrough(selection.hasFormat('strikethrough'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerUpdateListener(({editorState}) => {
|
||||
editorState.read(
|
||||
() => {
|
||||
$updateToolbar();
|
||||
},
|
||||
{editor},
|
||||
);
|
||||
}),
|
||||
editor.registerCommand(
|
||||
SELECTION_CHANGE_COMMAND,
|
||||
(_payload, _newEditor) => {
|
||||
$updateToolbar();
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_UNDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanUndo(payload);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
editor.registerCommand(
|
||||
CAN_REDO_COMMAND,
|
||||
(payload) => {
|
||||
setCanRedo(payload);
|
||||
return false;
|
||||
},
|
||||
COMMAND_PRIORITY_LOW,
|
||||
),
|
||||
);
|
||||
}, [editor, $updateToolbar]);
|
||||
|
||||
return (
|
||||
<div className="toolbar" ref={toolbarRef}>
|
||||
<ToolbarButton
|
||||
disabled={!canUndo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(UNDO_COMMAND, undefined);
|
||||
}}
|
||||
text="Undo"
|
||||
icon="bx bx-undo"
|
||||
/>
|
||||
<ToolbarButton
|
||||
disabled={!canRedo}
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(REDO_COMMAND, undefined);
|
||||
}}
|
||||
text="Redo"
|
||||
icon="bx bx-redo"
|
||||
/>
|
||||
<Divider />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
|
||||
}}
|
||||
active={isBold}
|
||||
text="Format Bold"
|
||||
icon="bx bx-bold"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
|
||||
}}
|
||||
active={isItalic}
|
||||
text="Format Italics"
|
||||
icon="bx bx-italic"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
|
||||
}}
|
||||
active={isUnderline}
|
||||
text="Format Underline"
|
||||
icon="bx bx-underline"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
|
||||
}}
|
||||
active={isStrikethrough}
|
||||
text="Format Strikethrough"
|
||||
icon="bx bx-strikethrough"
|
||||
/>
|
||||
<Divider />
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left');
|
||||
}}
|
||||
text="Left Align"
|
||||
icon="bx bx-align-left"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center');
|
||||
}}
|
||||
text="Center Align"
|
||||
icon="bx bx-align-middle"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')}
|
||||
text="Right Align"
|
||||
icon="bx bx-align-right"
|
||||
/>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')}
|
||||
text="Justify Align"
|
||||
icon="bx bx-align-justify"
|
||||
/>{' '}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarButton(props: Pick<ActionButtonProps, "icon" | "disabled" | "onClick" | "text">) {
|
||||
return (
|
||||
<ActionButton
|
||||
className="toolbar-item"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
.note-detail-editable-text .lexical-wrapper {
|
||||
color: var(--main-text-color);
|
||||
font-family: var(--main-font-family);
|
||||
font-size: var(--main-font-size);
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
position: relative;
|
||||
margin-inline: var(--content-margin-inline);
|
||||
|
||||
>div[contenteditable="true"] {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.lexical-placeholder {
|
||||
opacity: 0.5;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import "./index.css";
|
||||
|
||||
import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
|
||||
import {LexicalComposer} from '@lexical/react/LexicalComposer';
|
||||
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
|
||||
import {ContentEditable} from '@lexical/react/LexicalContentEditable';
|
||||
import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary';
|
||||
import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
|
||||
import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin';
|
||||
import {$createRangeSelection, $getRoot, $setSelection, CLEAR_HISTORY_COMMAND} from 'lexical';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { useEditorSpacedUpdate, useTriliumEvent } from '../../../react/hooks';
|
||||
import { TypeWidgetProps } from "../../type_widget";
|
||||
import ToolbarPlugin from "./ToolbarPlugin";
|
||||
|
||||
const theme = {
|
||||
// Theme styling goes here
|
||||
//...
|
||||
};
|
||||
|
||||
// Catch any errors that occur during Lexical updates and log them
|
||||
// or throw them as needed. If you don't throw them, Lexical will
|
||||
// try to recover gracefully without losing user data.
|
||||
function onError(error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
export default function LexicalText(props: TypeWidgetProps) {
|
||||
const initialConfig = {
|
||||
namespace: 'MyEditor',
|
||||
theme,
|
||||
onError,
|
||||
};
|
||||
|
||||
const placeholder = (
|
||||
<div className="lexical-placeholder">
|
||||
Enter some text...
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<ToolbarPlugin />
|
||||
<div className="lexical-wrapper">
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable /> as never}
|
||||
placeholder={placeholder as never}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</div>
|
||||
<HistoryPlugin />
|
||||
<AutoFocusPlugin />
|
||||
<ScrollToEndPlugin />
|
||||
<CustomEditorPersistencePlugin {...props} />
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomEditorPersistencePlugin({ note, noteContext }: TypeWidgetProps) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
const spacedUpdate = useEditorSpacedUpdate({
|
||||
note,
|
||||
noteContext,
|
||||
noteType: "text",
|
||||
getData() {
|
||||
return {
|
||||
content: JSON.stringify(editor.toJSON().editorState)
|
||||
};
|
||||
},
|
||||
onContentChange(newContent) {
|
||||
if (!newContent) {
|
||||
editor.update(() => {
|
||||
$getRoot().clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const editorState = editor.parseEditorState(newContent);
|
||||
editor.setEditorState(editorState);
|
||||
} catch (err) {
|
||||
console.error("Error parsing Lexical content", err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Clear the history whenever note changes.
|
||||
useEffect(() => {
|
||||
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
|
||||
}, [ editor, note ]);
|
||||
|
||||
// Detect changes in content.
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(() => {
|
||||
spacedUpdate.scheduleUpdate();
|
||||
});
|
||||
}, [ spacedUpdate, editor ]);
|
||||
}
|
||||
|
||||
function ScrollToEndPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useTriliumEvent("scrollToEnd", () => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
const lastChild = root.getLastDescendant();
|
||||
if (lastChild) {
|
||||
const selection = $createRangeSelection();
|
||||
selection.anchor.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
|
||||
selection.focus.set(lastChild.getKey(), lastChild.getTextContentSize(), 'text');
|
||||
$setSelection(selection);
|
||||
}
|
||||
});
|
||||
editor.focus();
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { expect,test } from "@playwright/test";
|
||||
|
||||
import App from "../support/app";
|
||||
|
||||
const TEXT_NOTE_TITLE = "Text notes";
|
||||
@@ -32,8 +33,7 @@ test("Open the note in the correct split pane", async ({ page, context }) => {
|
||||
await noteContent.focus();
|
||||
|
||||
// Click the search result in the second split.
|
||||
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
|
||||
.nth(1).click();
|
||||
await app.getNoteAutocompleteSuggestion(resultsSelector, CODE_NOTE_TITLE).click();
|
||||
|
||||
await expect(split2).toContainText(CODE_NOTE_TITLE);
|
||||
});
|
||||
@@ -69,4 +69,4 @@ test("Can directly focus the autocomplete input within the split", async ({ page
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
await expect(autocomplete).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ export default class App {
|
||||
readonly currentNoteSplitContent: Locator;
|
||||
readonly sidebar: Locator;
|
||||
private isMobile: boolean = false;
|
||||
private readonly noteAutocompleteSuggestionSelector = ".aa-suggestion:not(.create-note-action):not(.search-notes-action):not(.command-action):not(.external-link-action)";
|
||||
|
||||
constructor(page: Page, context: BrowserContext) {
|
||||
this.page = page;
|
||||
@@ -76,12 +77,19 @@ export default class App {
|
||||
|
||||
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
|
||||
await expect(resultsSelector).toContainText(noteTitle);
|
||||
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
|
||||
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
|
||||
const suggestionSelector = resultsSelector
|
||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
||||
.first();
|
||||
await expect(suggestionSelector).toContainText(noteTitle);
|
||||
await suggestionSelector.click();
|
||||
}
|
||||
|
||||
getNoteAutocompleteSuggestion(resultsContainer: Locator, noteTitle: string) {
|
||||
return resultsContainer
|
||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
||||
.first();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.page.locator(".launcher-button.bx-cog").click();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"main": "./src/main.ts",
|
||||
"scripts": {
|
||||
"dev": "cross-env NODE_ENV=development TRILIUM_ENV=dev TRILIUM_DATA_DIR=data TRILIUM_RESOURCE_DIR=src tsx watch --ignore '../client/node_modules/.vite-temp' ./src/main.ts",
|
||||
"dev-alt": "cross-env NODE_ENV=development TRILIUM_ENV=dev TRILIUM_DATA_DIR=data2 TRILIUM_RESOURCE_DIR=src tsx watch --ignore '../client/node_modules/.vite-temp' ./src/main.ts",
|
||||
"start-no-dir": "cross-env NODE_ENV=development TRILIUM_ENV=dev TRILIUM_RESOURCE_DIR=src tsx watch --ignore '../client/node_modules/.vite-temp' ./src/main.ts",
|
||||
"edit-integration-db": "cross-env NODE_ENV=development TRILIUM_PORT=8086 TRILIUM_ENV=dev TRILIUM_DATA_DIR=spec/db TRILIUM_INTEGRATION_TEST=edit TRILIUM_RESOURCE_DIR=src tsx watch --ignore '../client/node_modules/.vite-temp' ./src/main.ts",
|
||||
"build": "tsx scripts/build.ts",
|
||||
@@ -126,7 +125,7 @@
|
||||
"tmp": "0.2.5",
|
||||
"turnish": "1.8.0",
|
||||
"unescape": "1.0.1",
|
||||
"vite": "8.0.1",
|
||||
"vite": "8.0.0",
|
||||
"ws": "8.19.0",
|
||||
"xml2js": "0.6.2",
|
||||
"yauzl": "3.2.1"
|
||||
|
||||
@@ -58,35 +58,35 @@
|
||||
<div class="alert alert-warning" id="alert" style="display: none;">
|
||||
</div>
|
||||
|
||||
<div id="setup-type-section" style="margin-top: 20px;">
|
||||
<form id="setup-type-form">
|
||||
<div id="setup-type" data-bind="visible: step() == 'setup-type'" style="margin-top: 20px;">
|
||||
<form data-bind="submit: selectSetupType">
|
||||
|
||||
<div class="radio" style="margin-bottom: 15px;">
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="setup-type" value="new-document">
|
||||
<input type="radio" name="setup-type" value="new-document" data-bind="checked: setupType">
|
||||
<%= t("setup.new-document") %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="radio" style="margin-bottom: 15px;">
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="setup-type" value="sync-from-desktop">
|
||||
<input type="radio" name="setup-type" value="sync-from-desktop" data-bind="checked: setupType">
|
||||
<%= t("setup.sync-from-desktop") %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="radio" style="margin-bottom: 15px;">
|
||||
<label class="tn-radio">
|
||||
<input type="radio" name="setup-type" value="sync-from-server">
|
||||
<input type="radio" name="setup-type" value="sync-from-server" data-bind="checked: setupType">
|
||||
<%= t("setup.sync-from-server") %>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="setup-type-next" class="btn btn-primary" disabled><%= t("setup.next") %></button>
|
||||
<button type="submit" data-bind="disable: !setupTypeSelected()" class="btn btn-primary"><%= t("setup.next") %></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="new-document-in-progress-section">
|
||||
<div data-bind="visible: step() == 'new-document-in-progress'">
|
||||
<h2><%= t("setup.init-in-progress") %></h2>
|
||||
|
||||
<div style="display: flex; justify-content: flex-start; margin-top: 20px;">
|
||||
@@ -103,7 +103,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sync-from-desktop-section">
|
||||
<div data-bind="visible: step() == 'sync-from-desktop'">
|
||||
<h2><%= t("setup_sync-from-desktop.heading") %></h2>
|
||||
|
||||
<p><%= t("setup_sync-from-desktop.description") %></p>
|
||||
@@ -117,11 +117,11 @@
|
||||
<li><%- t("setup_sync-from-desktop.step6", { link: `<a href="/">${t("setup_sync-from-desktop.step6-here")}</a>` }) %></li>
|
||||
</ol>
|
||||
|
||||
<button type="button" data-action="back" class="btn btn-secondary">Back</button>
|
||||
<button type="button" data-bind="click: back" class="btn btn-secondary">Back</button>
|
||||
</div>
|
||||
|
||||
<div id="sync-from-server-section">
|
||||
<form id="sync-from-server-form">
|
||||
<div data-bind="visible: step() == 'sync-from-server'">
|
||||
<form data-bind="submit: finish">
|
||||
|
||||
<h2><%= t("setup_sync-from-server.heading") %></h2>
|
||||
|
||||
@@ -129,27 +129,27 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sync-server-host"><%= t("setup_sync-from-server.server-host") %></label>
|
||||
<input type="text" id="sync-server-host" class="form-control" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
|
||||
<input type="text" id="syncServerHost" class="form-control" data-bind="value: syncServerHost" placeholder="<%= t("setup_sync-from-server.server-host-placeholder") %>">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sync-proxy"><%= t("setup_sync-from-server.proxy-server") %></label>
|
||||
<input type="text" id="sync-proxy" class="form-control" placeholder="<%= t("setup_sync-from-server.proxy-server-placeholder") %>">
|
||||
<input type="text" id="sync-proxy" class="form-control" data-bind="value: syncProxy" placeholder="<%= t("setup_sync-from-server.proxy-server-placeholder") %>">
|
||||
|
||||
<p><strong><%= t("setup_sync-from-server.note") %></strong> <%= t("setup_sync-from-server.proxy-instruction") %></p>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 8px;">
|
||||
<label for="password"><%= t("setup_sync-from-server.password") %></label>
|
||||
<input type="password" id="password" class="form-control" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
|
||||
<input type="password" id="password" class="form-control" data-bind="value: password" placeholder="<%= t("setup_sync-from-server.password-placeholder") %>">
|
||||
</div>
|
||||
|
||||
<button type="button" data-action="back" class="btn btn-secondary"><%= t("setup_sync-from-server.back") %></button>
|
||||
<button type="button" data-bind="click: back" class="btn btn-secondary"><%= t("setup_sync-from-server.back") %></button>
|
||||
|
||||
<button type="submit" class="btn btn-primary"><%= t("setup_sync-from-server.finish-setup") %></button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
<div id="sync-in-progress-section">
|
||||
<div data-bind="visible: step() == 'sync-in-progress'">
|
||||
<h2><%= t("setup_sync-in-progress.heading") %></h2>
|
||||
|
||||
<div class="alert alert-success"><%= t("setup_sync-in-progress.successful") %></div>
|
||||
|
||||
@@ -71,6 +71,27 @@ function getAttributeNames(type: string, nameLike: string) {
|
||||
[type, `%${nameLike}%`]
|
||||
);
|
||||
|
||||
// Also include attribute definitions (e.g. 'relation:*' or 'label:*') which are saved as type='label'
|
||||
if (type === "relation" || type === "label") {
|
||||
const prefix = `${type}:`;
|
||||
const defNames = sql.getColumn<string>(
|
||||
/*sql*/`SELECT DISTINCT name
|
||||
FROM attributes
|
||||
WHERE isDeleted = 0
|
||||
AND type = 'label'
|
||||
AND name LIKE ?`,
|
||||
[`${prefix}%${nameLike}%`]
|
||||
);
|
||||
for (const dn of defNames) {
|
||||
if (dn.startsWith(prefix)) {
|
||||
const stripped = dn.substring(prefix.length);
|
||||
if (!names.includes(stripped)) {
|
||||
names.push(stripped);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const attr of BUILTIN_ATTRIBUTES) {
|
||||
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
|
||||
names.push(attr.name);
|
||||
|
||||
@@ -7,7 +7,7 @@ function parse(value: string): DefinitionObject {
|
||||
for (const token of tokens) {
|
||||
if (token === "promoted") {
|
||||
defObj.isPromoted = true;
|
||||
} else if (["text", "textarea", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) {
|
||||
} else if (["text", "number", "boolean", "date", "datetime", "time", "url"].includes(token)) {
|
||||
defObj.labelType = token;
|
||||
} else if (["single", "multi"].includes(token)) {
|
||||
defObj.multiplicity = token;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import safeCompare from "safe-compare";
|
||||
|
||||
import type { NextFunction, Request, Response, Router } from "express";
|
||||
import type { Request, Response, Router } from "express";
|
||||
|
||||
import shaca from "./shaca/shaca.js";
|
||||
import shacaLoader from "./shaca/shaca_loader.js";
|
||||
@@ -10,16 +10,6 @@ import type SNote from "./shaca/entities/snote.js";
|
||||
import type SAttachment from "./shaca/entities/sattachment.js";
|
||||
import { getDefaultTemplatePath, renderNoteContent } from "./content_renderer.js";
|
||||
import utils from "../services/utils.js";
|
||||
import { isShareDbReady } from "./sql.js";
|
||||
|
||||
function assertShareDbReady(_req: Request, res: Response, next: NextFunction) {
|
||||
if (!isShareDbReady()) {
|
||||
res.status(503).send("The application is still initializing. Please try again in a moment.");
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
function addNoIndexHeader(note: SNote, res: Response) {
|
||||
if (note.isLabelTruthy("shareDisallowRobotIndexing")) {
|
||||
@@ -125,8 +115,6 @@ function render404(res: Response) {
|
||||
}
|
||||
|
||||
function register(router: Router) {
|
||||
// Guard: if the share DB is not yet initialized, return 503 for all /share routes.
|
||||
router.use("/share", assertShareDbReady);
|
||||
|
||||
function renderNote(note: SNote, req: Request, res: Response) {
|
||||
if (!note) {
|
||||
|
||||
@@ -5,14 +5,12 @@ import dataDir from "../services/data_dir.js";
|
||||
import sql_init from "../services/sql_init.js";
|
||||
|
||||
let dbConnection!: Database.Database;
|
||||
let dbConnectionReady = false;
|
||||
|
||||
sql_init.dbReady.then(() => {
|
||||
dbConnection = new Database(dataDir.DOCUMENT_PATH, {
|
||||
readonly: true,
|
||||
nativeBinding: process.env.BETTERSQLITE3_NATIVE_PATH || undefined
|
||||
});
|
||||
dbConnectionReady = true;
|
||||
|
||||
[`exit`, `SIGINT`, `SIGUSR1`, `SIGUSR2`, `SIGTERM`].forEach((eventType) => {
|
||||
process.on(eventType, () => {
|
||||
@@ -25,31 +23,18 @@ sql_init.dbReady.then(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function assertDbReady(): void {
|
||||
if (!dbConnectionReady) {
|
||||
throw new Error("Share database connection is not yet ready. The application may still be initializing.");
|
||||
}
|
||||
}
|
||||
|
||||
function getRawRows<T>(query: string, params = []): T[] {
|
||||
assertDbReady();
|
||||
return dbConnection.prepare(query).raw().all(params) as T[];
|
||||
}
|
||||
|
||||
function getRow<T>(query: string, params: string[] = []): T {
|
||||
assertDbReady();
|
||||
return dbConnection.prepare(query).get(params) as T;
|
||||
}
|
||||
|
||||
function getColumn<T>(query: string, params: string[] = []): T[] {
|
||||
assertDbReady();
|
||||
return dbConnection.prepare(query).pluck().all(params) as T[];
|
||||
}
|
||||
|
||||
export function isShareDbReady(): boolean {
|
||||
return dbConnectionReady;
|
||||
}
|
||||
|
||||
export default {
|
||||
getRawRows,
|
||||
getRow,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"typescript": "5.9.3",
|
||||
"user-agent-data-types": "0.4.2",
|
||||
"vite": "8.0.1",
|
||||
"vite": "8.0.0",
|
||||
"vitest": "4.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"server:build": "pnpm run --filter server build",
|
||||
"server:coverage": "pnpm run --filter server test --coverage",
|
||||
"server:start": "pnpm run --filter server dev",
|
||||
"server:start-alt": "pnpm run --filter server dev-alt",
|
||||
"server:start-prod": "pnpm run --filter server start-prod",
|
||||
"desktop:start": "pnpm run --filter desktop dev",
|
||||
"desktop:build": "pnpm run --filter desktop build",
|
||||
@@ -62,7 +61,7 @@
|
||||
"eslint": "10.0.3",
|
||||
"eslint-config-preact": "2.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-playwright": "2.10.1",
|
||||
"eslint-plugin-playwright": "2.10.0",
|
||||
"eslint-plugin-simple-import-sort": "12.1.1",
|
||||
"happy-dom": "20.8.4",
|
||||
"http-server": "14.1.1",
|
||||
@@ -76,7 +75,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"upath": "2.0.1",
|
||||
"vite": "8.0.1",
|
||||
"vite": "8.0.0",
|
||||
"vite-plugin-dts": "4.5.4",
|
||||
"vitest": "4.1.0"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"ckeditor5-metadata.json"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
|
||||
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
|
||||
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
|
||||
"@ckeditor/ckeditor5-package-tools": "5.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.1",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"ckeditor5-premium-features": "47.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@smithy/middleware-retry": "4.4.44",
|
||||
"@smithy/middleware-retry": "4.4.43",
|
||||
"@types/jquery": "4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"fuse.js": "7.1.0",
|
||||
"katex": "0.16.39",
|
||||
"katex": "0.16.38",
|
||||
"mermaid": "11.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
5051
pnpm-lock.yaml
generated
5051
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user