mirror of
https://github.com/zadam/trilium.git
synced 2026-03-12 23:20:26 +01:00
Compare commits
34 Commits
autocomple
...
renovate/h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cafcdf838f | ||
|
|
caa428c1a2 | ||
|
|
517c721664 | ||
|
|
a8cdaa69f7 | ||
|
|
53d221ef34 | ||
|
|
5450fde472 | ||
|
|
808446cef5 | ||
|
|
921c663199 | ||
|
|
1b8a75b615 | ||
|
|
f78ced5bc3 | ||
|
|
81bf5f4f3b | ||
|
|
aaed368670 | ||
|
|
5e8de14721 | ||
|
|
634ab5b5c0 | ||
|
|
906889a035 | ||
|
|
ab9d50b905 | ||
|
|
e61b7c7cfc | ||
|
|
1c628fba4c | ||
|
|
f8b4c6cb15 | ||
|
|
3edd8f6c5a | ||
|
|
7777f72893 | ||
|
|
9af85b767b | ||
|
|
73260b91eb | ||
|
|
2858f63873 | ||
|
|
15ca328727 | ||
|
|
5b3fbecc0f | ||
|
|
365d0f0aac | ||
|
|
e86d84c463 | ||
|
|
6b974c2ac7 | ||
|
|
d2afcbb98d | ||
|
|
68a122fcf5 | ||
|
|
92f0144b48 | ||
|
|
a5a345728c | ||
|
|
23890e64e9 |
@@ -16,7 +16,6 @@
|
|||||||
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@algolia/autocomplete-js": "1.19.6",
|
|
||||||
"@excalidraw/excalidraw": "0.18.0",
|
"@excalidraw/excalidraw": "0.18.0",
|
||||||
"@fullcalendar/core": "6.1.20",
|
"@fullcalendar/core": "6.1.20",
|
||||||
"@fullcalendar/daygrid": "6.1.20",
|
"@fullcalendar/daygrid": "6.1.20",
|
||||||
@@ -45,6 +44,7 @@
|
|||||||
"@univerjs/preset-sheets-sort": "0.16.1",
|
"@univerjs/preset-sheets-sort": "0.16.1",
|
||||||
"@univerjs/presets": "0.16.1",
|
"@univerjs/presets": "0.16.1",
|
||||||
"@zumer/snapdom": "2.1.0",
|
"@zumer/snapdom": "2.1.0",
|
||||||
|
"autocomplete.js": "0.38.1",
|
||||||
"bootstrap": "5.3.8",
|
"bootstrap": "5.3.8",
|
||||||
"boxicons": "2.1.4",
|
"boxicons": "2.1.4",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
|
||||||
|
|
||||||
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
|
|
||||||
import bundleService from "../services/bundle.js";
|
import bundleService from "../services/bundle.js";
|
||||||
import dateNoteService from "../services/date_notes.js";
|
import dateNoteService from "../services/date_notes.js";
|
||||||
import froca from "../services/froca.js";
|
import froca from "../services/froca.js";
|
||||||
@@ -198,7 +197,7 @@ export default class Entrypoints extends Component {
|
|||||||
|
|
||||||
hideAllPopups() {
|
hideAllPopups() {
|
||||||
if (utils.isDesktop()) {
|
if (utils.isDesktop()) {
|
||||||
closeAllHeadlessAutocompletes();
|
$(".aa-input").autocomplete("close");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
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 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 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";
|
||||||
|
|
||||||
interface TabState {
|
interface TabState {
|
||||||
contexts: NoteContext[];
|
contexts: NoteContext[];
|
||||||
@@ -430,7 +429,10 @@ export default class TabManager extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// close dangling autocompletes after closing the tab
|
// close dangling autocompletes after closing the tab
|
||||||
closeAllHeadlessAutocompletes();
|
const $autocompleteEl = $(".aa-input");
|
||||||
|
if ("autocomplete" in $autocompleteEl) {
|
||||||
|
$autocompleteEl.autocomplete("close");
|
||||||
|
}
|
||||||
|
|
||||||
// close dangling tooltips
|
// close dangling tooltips
|
||||||
$("body > div.tooltip").remove();
|
$("body > div.tooltip").remove();
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "autocomplete.js/index_jquery.js";
|
||||||
|
|
||||||
import type ElectronRemote from "@electron/remote";
|
import type ElectronRemote from "@electron/remote";
|
||||||
import type Electron from "electron";
|
import type Electron from "electron";
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,17 @@ async function initJQuery() {
|
|||||||
const $ = (await import("jquery")).default;
|
const $ = (await import("jquery")).default;
|
||||||
window.$ = $;
|
window.$ = $;
|
||||||
window.jQuery = $;
|
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() {
|
async function setupGlob() {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import "autocomplete.js/index_jquery.js";
|
||||||
|
|
||||||
import appContext from "./components/app_context.js";
|
import appContext from "./components/app_context.js";
|
||||||
import glob from "./services/glob.js";
|
import glob from "./services/glob.js";
|
||||||
import noteAutocompleteService from "./services/note_autocomplete.js";
|
import noteAutocompleteService from "./services/note_autocomplete.js";
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ 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).$ = $;
|
||||||
(window as any).jQuery = $;
|
(window as any).jQuery = $;
|
||||||
await loadBootstrap();
|
await loadBootstrap();
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
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,450 +1,114 @@
|
|||||||
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
|
|
||||||
import { createAutocomplete } from "@algolia/autocomplete-core";
|
|
||||||
|
|
||||||
import type { AttributeType } from "../entities/fattribute.js";
|
import type { AttributeType } from "../entities/fattribute.js";
|
||||||
import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js";
|
|
||||||
import server from "./server.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>;
|
$el: JQuery<HTMLElement>;
|
||||||
attributeType?: AttributeType | (() => AttributeType);
|
attributeType?: AttributeType | (() => AttributeType);
|
||||||
open: boolean;
|
open: boolean;
|
||||||
/** Called when the user selects a value or the panel closes */
|
|
||||||
onValueChange?: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Instance tracking
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface ManagedInstance {
|
|
||||||
autocomplete: CoreAutocompleteApi<NameItem>;
|
|
||||||
panelEl: HTMLElement;
|
|
||||||
cleanup: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDeactivate();
|
|
||||||
});
|
|
||||||
li.addEventListener("mousedown", (e) => {
|
|
||||||
e.preventDefault(); // prevent input blur
|
|
||||||
e.stopPropagation();
|
|
||||||
onSelect(item);
|
|
||||||
});
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
panelEl.appendChild(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Attribute name autocomplete — new (autocomplete-core, headless)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
|
|
||||||
const inputEl = $el[0] as HTMLInputElement;
|
|
||||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
|
||||||
autocomplete.setQuery(inputEl.value || "");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Already initialized — just open if requested
|
|
||||||
if (instanceMap.has(inputEl)) {
|
|
||||||
if (open) {
|
|
||||||
const inst = instanceMap.get(inputEl)!;
|
|
||||||
syncQueryFromInputValue(inst.autocomplete);
|
|
||||||
inst.autocomplete.setIsOpen(true);
|
|
||||||
inst.autocomplete.refresh();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const panelController = createHeadlessPanelController({ inputEl });
|
|
||||||
const { panelEl } = panelController;
|
|
||||||
|
|
||||||
let isPanelOpen = false;
|
|
||||||
let hasActiveItem = false;
|
|
||||||
|
|
||||||
const autocomplete = createAutocomplete<NameItem>({
|
|
||||||
openOnFocus: true,
|
|
||||||
defaultActiveItemId: 0,
|
|
||||||
shouldPanelOpen() {
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
getSources({ query }) {
|
|
||||||
return [
|
|
||||||
withHeadlessSourceDefaults({
|
|
||||||
sourceId: "attribute-names",
|
|
||||||
getItems() {
|
|
||||||
const type = typeof attributeType === "function" ? attributeType() : attributeType;
|
|
||||||
return server
|
|
||||||
.get<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);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
onStateChange({ state }) {
|
|
||||||
isPanelOpen = state.isOpen;
|
|
||||||
hasActiveItem = state.activeItemId !== null;
|
|
||||||
|
|
||||||
// Render items
|
|
||||||
const collections = state.collections;
|
|
||||||
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
|
|
||||||
const activeId = state.activeItemId ?? null;
|
|
||||||
|
|
||||||
if (state.isOpen && items.length > 0) {
|
|
||||||
renderItems(
|
|
||||||
panelEl,
|
|
||||||
items,
|
|
||||||
activeId,
|
|
||||||
(item) => {
|
|
||||||
inputEl.value = item.name;
|
|
||||||
autocomplete.setQuery(item.name);
|
|
||||||
autocomplete.setIsOpen(false);
|
|
||||||
onValueChange?.(item.name);
|
|
||||||
},
|
|
||||||
(index) => {
|
|
||||||
autocomplete.setActiveItemId(index);
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
autocomplete.setActiveItemId(null);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
panelController.startPositioning();
|
|
||||||
} else {
|
|
||||||
panelController.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.isOpen) {
|
|
||||||
panelController.hide();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
|
|
||||||
autocomplete.setIsOpen(false);
|
|
||||||
panelController.hide();
|
|
||||||
});
|
|
||||||
|
|
||||||
const cleanupInputBindings = bindAutocompleteInput<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) {
|
|
||||||
syncQueryFromInputValue(autocomplete);
|
|
||||||
autocomplete.setIsOpen(true);
|
|
||||||
autocomplete.refresh();
|
|
||||||
panelController.startPositioning();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Label value autocomplete (headless autocomplete-core)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface LabelValueInitOptions {
|
|
||||||
$el: JQuery<HTMLElement>;
|
|
||||||
open: boolean;
|
|
||||||
nameCallback?: () => string;
|
nameCallback?: () => string;
|
||||||
onValueChange?: (value: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
|
/**
|
||||||
const inputEl = $el[0] as HTMLInputElement;
|
* @param $el - element on which to init autocomplete
|
||||||
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
|
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
|
||||||
autocomplete.setQuery(inputEl.value || "");
|
* @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;
|
||||||
|
|
||||||
if (instanceMap.has(inputEl)) {
|
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
|
||||||
if (open) {
|
const result = names.map((name) => ({ name }));
|
||||||
const inst = instanceMap.get(inputEl)!;
|
|
||||||
syncQueryFromInputValue(inst.autocomplete);
|
cb(result);
|
||||||
inst.autocomplete.setIsOpen(true);
|
}
|
||||||
inst.autocomplete.refresh();
|
}
|
||||||
}
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$el.on("autocomplete:opened", () => {
|
||||||
|
if ($el.attr("readonly")) {
|
||||||
|
$el.autocomplete("close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
$el.autocomplete("open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
|
||||||
|
if ($el.hasClass("aa-input")) {
|
||||||
|
// we reinit every time because autocomplete seems to have a bug where it retains state from last
|
||||||
|
// open even though the value was reset
|
||||||
|
$el.autocomplete("destroy");
|
||||||
|
}
|
||||||
|
|
||||||
|
let attributeName = "";
|
||||||
|
if (nameCallback) {
|
||||||
|
attributeName = nameCallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributeName.trim() === "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const panelController = createHeadlessPanelController({ inputEl });
|
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
|
||||||
const { panelEl } = panelController;
|
|
||||||
|
|
||||||
let isPanelOpen = false;
|
if (attributeValues.length === 0) {
|
||||||
let hasActiveItem = false;
|
return;
|
||||||
let isSelecting = false;
|
}
|
||||||
|
|
||||||
let cachedAttributeName = "";
|
$el.autocomplete(
|
||||||
let cachedAttributeValues: NameItem[] = [];
|
{
|
||||||
|
appendTo: document.querySelector("body"),
|
||||||
const handleSelect = (item: NameItem) => {
|
hint: false,
|
||||||
isSelecting = true;
|
openOnFocus: false, // handled manually
|
||||||
inputEl.value = item.name;
|
minLength: 0,
|
||||||
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
|
tabAutocomplete: false
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
displayKey: "value",
|
||||||
|
cache: false,
|
||||||
|
source: async function (term, cb) {
|
||||||
|
term = term.toLowerCase();
|
||||||
|
|
||||||
getSources({ query }) {
|
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
|
||||||
return [
|
|
||||||
withHeadlessSourceDefaults({
|
|
||||||
sourceId: "attribute-values",
|
|
||||||
async getItems() {
|
|
||||||
const attributeName = nameCallback ? nameCallback() : "";
|
|
||||||
if (!attributeName.trim()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
|
cb(filtered);
|
||||||
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) {
|
$el.on("autocomplete:opened", () => {
|
||||||
panelController.hide();
|
if ($el.attr("readonly")) {
|
||||||
}
|
$el.autocomplete("close");
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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) {
|
if (open) {
|
||||||
syncQueryFromInputValue(autocomplete);
|
$el.autocomplete("open");
|
||||||
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 {
|
export default {
|
||||||
initAttributeNameAutocomplete,
|
initAttributeNameAutocomplete,
|
||||||
destroyAutocomplete,
|
initLabelValueAutocomplete
|
||||||
initLabelValueAutocomplete,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
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,11 +1,9 @@
|
|||||||
import { Modal } from "bootstrap";
|
import { Modal } from "bootstrap";
|
||||||
|
|
||||||
import appContext from "../components/app_context.js";
|
import appContext from "../components/app_context.js";
|
||||||
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
|
||||||
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
|
||||||
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
|
||||||
import { closeAllHeadlessAutocompletes } from "./autocomplete_core.js";
|
|
||||||
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
import { focusSavedElement, saveFocusedElement } from "./focus.js";
|
||||||
|
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
|
||||||
|
|
||||||
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
|
||||||
if (closeActDialog) {
|
if (closeActDialog) {
|
||||||
@@ -17,7 +15,10 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
|
|||||||
Modal.getOrCreateInstance($dialog[0], config).show();
|
Modal.getOrCreateInstance($dialog[0], config).show();
|
||||||
|
|
||||||
$dialog.on("hidden.bs.modal", () => {
|
$dialog.on("hidden.bs.modal", () => {
|
||||||
closeAllHeadlessAutocompletes();
|
const $autocompleteEl = $(".aa-input");
|
||||||
|
if ("autocomplete" in $autocompleteEl) {
|
||||||
|
$autocompleteEl.autocomplete("close");
|
||||||
|
}
|
||||||
|
|
||||||
if (!glob.activeDialog || glob.activeDialog === $dialog) {
|
if (!glob.activeDialog || glob.activeDialog === $dialog) {
|
||||||
focusSavedElement();
|
focusSavedElement();
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
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 server from "./server.js";
|
||||||
|
import appContext from "../components/app_context.js";
|
||||||
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
|
||||||
|
import type Component from "../components/component.js";
|
||||||
|
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
|
||||||
|
|
||||||
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
|
||||||
|
|
||||||
@@ -52,10 +51,7 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
|
|||||||
getActionsForScope("window").then((actions) => {
|
getActionsForScope("window").then((actions) => {
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
for (const shortcut of action.effectiveShortcuts ?? []) {
|
for (const shortcut of action.effectiveShortcuts ?? []) {
|
||||||
shortcutService.bindGlobalShortcut(shortcut, () => {
|
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
|
||||||
const ntxId = appContext.tabManager?.activeNtxId ?? null;
|
|
||||||
appContext.triggerCommand(action.actionName, { ntxId });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -892,6 +892,33 @@ table.promoted-attributes-in-tooltip th {
|
|||||||
opacity: 1;
|
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 {
|
.aa-dropdown-menu .aa-suggestion {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 16px;
|
padding: 6px 16px;
|
||||||
@@ -933,153 +960,6 @@ table.promoted-attributes-in-tooltip th {
|
|||||||
background-color: var(--active-item-background-color);
|
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 {
|
.help-button {
|
||||||
float: inline-end;
|
float: inline-end;
|
||||||
background: none;
|
background: none;
|
||||||
|
|||||||
@@ -128,8 +128,8 @@
|
|||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The headless autocomplete panel rendered into the empty-note results container */
|
/* The search results list */
|
||||||
.note-detail-empty .aa-core-panel--contained {
|
.note-detail-empty span.aa-dropdown-menu {
|
||||||
margin-top: 1em;
|
margin-top: 1em;
|
||||||
border: unset;
|
border: unset;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -803,12 +803,13 @@
|
|||||||
"web-view": "عرض الويب",
|
"web-view": "عرض الويب",
|
||||||
"mind-map": "خريطة ذهنية",
|
"mind-map": "خريطة ذهنية",
|
||||||
"geo-map": "خريطة جغرافية",
|
"geo-map": "خريطة جغرافية",
|
||||||
"task-list": "قائمة المهام"
|
"task-list": "قائمة المهام",
|
||||||
|
"spreadsheet": "جدول البيانات"
|
||||||
},
|
},
|
||||||
"shared_switch": {
|
"shared_switch": {
|
||||||
"shared": "مشترك",
|
"shared": "مشترك",
|
||||||
"toggle-on-title": "مشاركة الملاحظة",
|
"toggle-on-title": "مشاركة الملاحظة",
|
||||||
"toggle-off-title": "الغاء مشاركة الملاحظة"
|
"toggle-off-title": "إلغاء مشاركة الملاحظة"
|
||||||
},
|
},
|
||||||
"template_switch": {
|
"template_switch": {
|
||||||
"template": "قالب"
|
"template": "قالب"
|
||||||
@@ -1286,8 +1287,10 @@
|
|||||||
"search-for": "بحث ل \"{{term}}\""
|
"search-for": "بحث ل \"{{term}}\""
|
||||||
},
|
},
|
||||||
"protect_note": {
|
"protect_note": {
|
||||||
"toggle-off": "ازالة الحماية عن الملاحظة",
|
"toggle-off": "إزالة الحماية عن الملاحظة",
|
||||||
"toggle-on": "حماية الملاحظة"
|
"toggle-on": "حماية الملاحظة",
|
||||||
|
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
|
||||||
|
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
|
||||||
},
|
},
|
||||||
"open-help-page": "فتح صفحة المساعدة",
|
"open-help-page": "فتح صفحة المساعدة",
|
||||||
"empty": {
|
"empty": {
|
||||||
|
|||||||
@@ -1036,7 +1036,7 @@
|
|||||||
"file_preview_not_available": "File preview is not available for this file format.",
|
"file_preview_not_available": "File preview is not available for this file format.",
|
||||||
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
|
"too_big": "The preview only shows the first {{maxNumChars}} characters of the file for performance reasons. Download the file and open it externally to be able to see the entire content."
|
||||||
},
|
},
|
||||||
"video": {
|
"media": {
|
||||||
"play": "Play (Space)",
|
"play": "Play (Space)",
|
||||||
"pause": "Pause (Space)",
|
"pause": "Pause (Space)",
|
||||||
"back-10s": "Back 10s (Left arrow key)",
|
"back-10s": "Back 10s (Left arrow key)",
|
||||||
@@ -1051,7 +1051,7 @@
|
|||||||
"exit-picture-in-picture": "Exit picture-in-picture",
|
"exit-picture-in-picture": "Exit picture-in-picture",
|
||||||
"fullscreen": "Fullscreen (F)",
|
"fullscreen": "Fullscreen (F)",
|
||||||
"exit-fullscreen": "Exit fullscreen",
|
"exit-fullscreen": "Exit fullscreen",
|
||||||
"unsupported-format": "Video preview is not available for this file format.",
|
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
|
||||||
"zoom-to-fit": "Zoom to fill",
|
"zoom-to-fit": "Zoom to fill",
|
||||||
"zoom-reset": "Reset zoom to fill"
|
"zoom-reset": "Reset zoom to fill"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1780,7 +1780,8 @@
|
|||||||
"ai-chat": "Czat AI",
|
"ai-chat": "Czat AI",
|
||||||
"task-list": "Lista zadań",
|
"task-list": "Lista zadań",
|
||||||
"new-feature": "Nowość",
|
"new-feature": "Nowość",
|
||||||
"collections": "Kolekcje"
|
"collections": "Kolekcje",
|
||||||
|
"spreadsheet": "Arkusz"
|
||||||
},
|
},
|
||||||
"protect_note": {
|
"protect_note": {
|
||||||
"toggle-on": "Chroń notatkę",
|
"toggle-on": "Chroń notatkę",
|
||||||
|
|||||||
28
apps/client/src/types.d.ts
vendored
28
apps/client/src/types.d.ts
vendored
@@ -6,6 +6,7 @@ import type { PrintReport } from "./print";
|
|||||||
import type { lint } from "./services/eslint";
|
import type { lint } from "./services/eslint";
|
||||||
import type { Froca } from "./services/froca-interface";
|
import type { Froca } from "./services/froca-interface";
|
||||||
import { Library } from "./services/library_loader";
|
import { Library } from "./services/library_loader";
|
||||||
|
import { Suggestion } from "./services/note_autocomplete";
|
||||||
import server from "./services/server";
|
import server from "./services/server";
|
||||||
import utils from "./services/utils";
|
import utils from "./services/utils";
|
||||||
|
|
||||||
@@ -82,7 +83,34 @@ declare global {
|
|||||||
"note-load-progress": CustomEvent<{ progress: number }>;
|
"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 {
|
interface JQuery {
|
||||||
|
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery<HTMLElement>;
|
||||||
|
|
||||||
getSelectedNotePath(): string | undefined;
|
getSelectedNotePath(): string | undefined;
|
||||||
getSelectedNoteId(): string | null;
|
getSelectedNoteId(): string | null;
|
||||||
setSelectedNotePath(notePath: string | null | undefined);
|
setSelectedNotePath(notePath: string | null | undefined);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from
|
|||||||
import NoteContext from "../components/note_context";
|
import NoteContext from "../components/note_context";
|
||||||
import FAttribute from "../entities/fattribute";
|
import FAttribute from "../entities/fattribute";
|
||||||
import FNote from "../entities/fnote";
|
import FNote from "../entities/fnote";
|
||||||
import attributeAutocompleteService from "../services/attribute_autocomplete";
|
|
||||||
import { Attribute } from "../services/attribute_parser";
|
import { Attribute } from "../services/attribute_parser";
|
||||||
import attributes from "../services/attributes";
|
import attributes from "../services/attributes";
|
||||||
import { t } from "../services/i18n";
|
import { t } from "../services/i18n";
|
||||||
@@ -37,7 +36,8 @@ interface CellProps {
|
|||||||
setCellToFocus(cell: Cell): void;
|
setCellToFocus(cell: Cell): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent;
|
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
|
||||||
|
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
|
||||||
|
|
||||||
export default function PromotedAttributes() {
|
export default function PromotedAttributes() {
|
||||||
const { note, componentId, noteContext } = useNoteContext();
|
const { note, componentId, noteContext } = useNoteContext();
|
||||||
@@ -200,7 +200,11 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
|||||||
}, [ cell, componentId, note, setCells ]);
|
}, [ cell, componentId, note, setCells ]);
|
||||||
const extraInputProps: InputHTMLAttributes = {};
|
const extraInputProps: InputHTMLAttributes = {};
|
||||||
|
|
||||||
useTextLabelAutocomplete(inputId, valueAttr, definition, setDraft);
|
useTextLabelAutocomplete(inputId, valueAttr, definition, (e) => {
|
||||||
|
if (e.currentTarget instanceof HTMLInputElement) {
|
||||||
|
setDraft(e.currentTarget.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// React to model changes.
|
// React to model changes.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -254,7 +258,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
|
|||||||
className="open-external-link-button"
|
className="open-external-link-button"
|
||||||
icon="bx bx-window-open"
|
icon="bx bx-window-open"
|
||||||
title={t("promoted_attributes.open_external_link")}
|
title={t("promoted_attributes.open_external_link")}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
|
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
|
||||||
const url = inputEl?.value;
|
const url = inputEl?.value;
|
||||||
if (url) {
|
if (url) {
|
||||||
@@ -409,31 +413,55 @@ function InputButton({ icon, className, title, onClick }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onValueChange: (value: string) => void) {
|
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onChangeListener: OnChangeListener) {
|
||||||
|
const [ attributeValues, setAttributeValues ] = useState<{ value: string }[] | null>(null);
|
||||||
|
|
||||||
|
// Obtain data.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (definition.labelType !== "text") {
|
if (definition.labelType !== "text") {
|
||||||
return;
|
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;
|
const el = document.getElementById(inputId) as HTMLInputElement | null;
|
||||||
if (!el) {
|
if (!el) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $input = $(el);
|
const $input = $(el);
|
||||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
$input.autocomplete(
|
||||||
$el: $input,
|
{
|
||||||
open: false,
|
appendTo: document.querySelector("body"),
|
||||||
nameCallback: () => valueAttr.name,
|
hint: false,
|
||||||
onValueChange: (value) => {
|
autoselect: false,
|
||||||
onValueChange(value);
|
openOnFocus: true,
|
||||||
}
|
minLength: 0,
|
||||||
});
|
tabAutocomplete: false
|
||||||
|
},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
displayKey: "value",
|
||||||
|
source (term, cb) {
|
||||||
|
term = term.toLowerCase();
|
||||||
|
|
||||||
return () => {
|
const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term));
|
||||||
attributeAutocompleteService.destroyAutocomplete($input);
|
|
||||||
};
|
cb(filtered);
|
||||||
}, [ definition.labelType, inputId, onValueChange, valueAttr.name ]);
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$input.off("autocomplete:selected");
|
||||||
|
$input.on("autocomplete:selected", onChangeListener);
|
||||||
|
|
||||||
|
return () => $input.autocomplete("destroy");
|
||||||
|
}, [ inputId, attributeValues, onChangeListener ]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch<StateUpdater<Cell[] | undefined>>) {
|
async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch<StateUpdater<Cell[] | undefined>>) {
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
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 { t } from "../../services/i18n.js";
|
||||||
|
import server from "../../services/server.js";
|
||||||
|
import froca from "../../services/froca.js";
|
||||||
import linkService from "../../services/link.js";
|
import linkService from "../../services/link.js";
|
||||||
|
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
|
||||||
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
import noteAutocompleteService from "../../services/note_autocomplete.js";
|
||||||
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
|
||||||
import server from "../../services/server.js";
|
import NoteContextAwareWidget from "../note_context_aware_widget.js";
|
||||||
import shortcutService from "../../services/shortcuts.js";
|
|
||||||
import SpacedUpdate from "../../services/spaced_update.js";
|
import SpacedUpdate from "../../services/spaced_update.js";
|
||||||
import utils from "../../services/utils.js";
|
import utils from "../../services/utils.js";
|
||||||
import NoteContextAwareWidget from "../note_context_aware_widget.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";
|
||||||
|
|
||||||
const TPL = /*html*/`
|
const TPL = /*html*/`
|
||||||
<div class="attr-detail tn-tool-dialog">
|
<div class="attr-detail tn-tool-dialog">
|
||||||
@@ -373,13 +372,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.$inputName.on("change", () => this.userEditedAttribute());
|
this.$inputName.on("change", () => this.userEditedAttribute());
|
||||||
|
this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||||
|
|
||||||
this.$inputName.on("focus", () => {
|
this.$inputName.on("focus", () => {
|
||||||
attributeAutocompleteService.initAttributeNameAutocomplete({
|
attributeAutocompleteService.initAttributeNameAutocomplete({
|
||||||
$el: this.$inputName,
|
$el: this.$inputName,
|
||||||
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
|
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
|
||||||
open: true,
|
open: true
|
||||||
onValueChange: () => this.userEditedAttribute(),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -392,12 +391,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.$inputValue.on("change", () => this.userEditedAttribute());
|
this.$inputValue.on("change", () => this.userEditedAttribute());
|
||||||
|
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
|
||||||
this.$inputValue.on("focus", () => {
|
this.$inputValue.on("focus", () => {
|
||||||
attributeAutocompleteService.initLabelValueAutocomplete({
|
attributeAutocompleteService.initLabelValueAutocomplete({
|
||||||
$el: this.$inputValue,
|
$el: this.$inputValue,
|
||||||
open: true,
|
open: true,
|
||||||
nameCallback: () => String(this.$inputName.val()),
|
nameCallback: () => String(this.$inputName.val())
|
||||||
onValueChange: () => this.userEditedAttribute(),
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -478,9 +477,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
|
|||||||
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
|
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
|
||||||
|
|
||||||
$(window).on("mousedown", (e) => {
|
$(window).on("mousedown", (e) => {
|
||||||
if (!$(e.target).closest(this.$widget[0]).length
|
if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) {
|
||||||
&& !$(e.target).closest(HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR).length
|
|
||||||
&& !$(e.target).closest("#context-menu-container").length) {
|
|
||||||
this.hide();
|
this.hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
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,17 +1,15 @@
|
|||||||
import type { JSX } from "preact";
|
|
||||||
import { useEffect,useRef, useState } from "preact/hooks";
|
|
||||||
|
|
||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
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 FormRadioGroup from "../react/FormRadioGroup";
|
|
||||||
import { useTriliumEvent } from "../react/hooks";
|
|
||||||
import Modal from "../react/Modal";
|
import Modal from "../react/Modal";
|
||||||
|
import Button from "../react/Button";
|
||||||
|
import FormRadioGroup from "../react/FormRadioGroup";
|
||||||
import NoteAutocomplete from "../react/NoteAutocomplete";
|
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 { logError } from "../../services/ws";
|
||||||
|
import FormGroup from "../react/FormGroup.js";
|
||||||
import { refToJQuerySelector } from "../react/react_utils";
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
|
||||||
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
type LinkType = "reference-link" | "external-link" | "hyper-link";
|
||||||
|
|
||||||
@@ -28,8 +26,6 @@ export default function AddLinkDialog() {
|
|||||||
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
|
||||||
const [ shown, setShown ] = useState(false);
|
const [ shown, setShown ] = useState(false);
|
||||||
const hasSubmittedRef = useRef(false);
|
const hasSubmittedRef = useRef(false);
|
||||||
const suggestionRef = useRef<Suggestion | null>(null);
|
|
||||||
const submitOnSelectionRef = useRef(false);
|
|
||||||
|
|
||||||
useTriliumEvent("showAddLinkDialog", opts => {
|
useTriliumEvent("showAddLinkDialog", opts => {
|
||||||
setOpts(opts);
|
setOpts(opts);
|
||||||
@@ -89,44 +85,15 @@ export default function AddLinkDialog() {
|
|||||||
.trigger("select");
|
.trigger("select");
|
||||||
}
|
}
|
||||||
|
|
||||||
function submitSelectedLink(selectedSuggestion: Suggestion | null) {
|
|
||||||
submitOnSelectionRef.current = false;
|
|
||||||
hasSubmittedRef.current = Boolean(selectedSuggestion);
|
|
||||||
|
|
||||||
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() {
|
function onSubmit() {
|
||||||
submitSelectedLink(suggestionRef.current);
|
hasSubmittedRef.current = true;
|
||||||
|
|
||||||
|
if (suggestion) {
|
||||||
|
// Insertion logic in onHidden because it needs focus.
|
||||||
|
setShown(false);
|
||||||
|
} else {
|
||||||
|
logError("No link to add.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const autocompleteRef = useRef<HTMLInputElement>(null);
|
const autocompleteRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -142,22 +109,19 @@ export default function AddLinkDialog() {
|
|||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
onShown={onShown}
|
onShown={onShown}
|
||||||
onHidden={() => {
|
onHidden={() => {
|
||||||
submitOnSelectionRef.current = false;
|
|
||||||
|
|
||||||
// Insert the link.
|
// Insert the link.
|
||||||
if (hasSubmittedRef.current && suggestionRef.current && opts) {
|
if (hasSubmittedRef.current && suggestion && opts) {
|
||||||
hasSubmittedRef.current = false;
|
hasSubmittedRef.current = false;
|
||||||
|
|
||||||
if (suggestionRef.current.notePath) {
|
if (suggestion.notePath) {
|
||||||
// Handle note link
|
// Handle note link
|
||||||
opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle);
|
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
|
||||||
} else if (suggestionRef.current.externalLink) {
|
} else if (suggestion.externalLink) {
|
||||||
// Handle external link
|
// Handle external link
|
||||||
opts.addLink(suggestionRef.current.externalLink, linkTitle, true);
|
opts.addLink(suggestion.externalLink, linkTitle, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suggestionRef.current = null;
|
|
||||||
setSuggestion(null);
|
setSuggestion(null);
|
||||||
setShown(false);
|
setShown(false);
|
||||||
}}
|
}}
|
||||||
@@ -166,9 +130,7 @@ export default function AddLinkDialog() {
|
|||||||
<FormGroup label={t("add_link.note")} name="note">
|
<FormGroup label={t("add_link.note")} name="note">
|
||||||
<NoteAutocomplete
|
<NoteAutocomplete
|
||||||
inputRef={autocompleteRef}
|
inputRef={autocompleteRef}
|
||||||
onChange={onSuggestionChange}
|
onChange={setSuggestion}
|
||||||
onKeyDownCapture={onAutocompleteKeyDownCapture}
|
|
||||||
onKeyUpCapture={onAutocompleteKeyUpCapture}
|
|
||||||
opts={{
|
opts={{
|
||||||
allowExternalLinks: true,
|
allowExternalLinks: true,
|
||||||
allowCreatingNotes: true
|
allowCreatingNotes: true
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
|
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 { useRef, useState } from "preact/hooks";
|
||||||
|
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
|
||||||
import appContext from "../../components/app_context";
|
import appContext from "../../components/app_context";
|
||||||
import commandRegistry from "../../services/command_registry";
|
import commandRegistry from "../../services/command_registry";
|
||||||
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";
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
import shortcutService from "../../services/shortcuts";
|
||||||
|
|
||||||
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
|
||||||
|
|
||||||
@@ -127,7 +126,7 @@ export default function JumpToNoteDialogComponent() {
|
|||||||
setIsCommandMode(text.startsWith(">"));
|
setIsCommandMode(text.startsWith(">"));
|
||||||
}}
|
}}
|
||||||
onChange={onItemSelected}
|
onChange={onItemSelected}
|
||||||
/>}
|
/>}
|
||||||
onShown={onShown}
|
onShown={onShown}
|
||||||
onHidden={() => setShown(false)}
|
onHidden={() => setShown(false)}
|
||||||
footer={!isCommandMode && <Button
|
footer={!isCommandMode && <Button
|
||||||
@@ -138,7 +137,7 @@ export default function JumpToNoteDialogComponent() {
|
|||||||
/>}
|
/>}
|
||||||
show={shown}
|
show={shown}
|
||||||
>
|
>
|
||||||
<div className="jump-to-note-results" ref={containerRef} />
|
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useRef, useState } from "preact/hooks";
|
import { useRef, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
import Button from "../react/Button";
|
import Button from "../react/Button";
|
||||||
import FormGroup from "../react/FormGroup";
|
|
||||||
import FormTextBox from "../react/FormTextBox";
|
|
||||||
import { useTriliumEvent } from "../react/hooks";
|
|
||||||
import Modal from "../react/Modal";
|
import Modal from "../react/Modal";
|
||||||
|
import FormTextBox from "../react/FormTextBox";
|
||||||
|
import FormGroup from "../react/FormGroup";
|
||||||
import { refToJQuerySelector } from "../react/react_utils";
|
import { refToJQuerySelector } from "../react/react_utils";
|
||||||
|
import { useTriliumEvent } from "../react/hooks";
|
||||||
|
|
||||||
// JQuery here is maintained for compatibility with existing code.
|
// JQuery here is maintained for compatibility with existing code.
|
||||||
interface ShownCallbackData {
|
interface ShownCallbackData {
|
||||||
@@ -41,7 +40,7 @@ export default function PromptDialog() {
|
|||||||
opts.current = newOpts;
|
opts.current = newOpts;
|
||||||
setValue(newOpts.defaultValue ?? "");
|
setValue(newOpts.defaultValue ?? "");
|
||||||
setShown(true);
|
setShown(true);
|
||||||
});
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -61,7 +60,7 @@ export default function PromptDialog() {
|
|||||||
answerRef.current?.select();
|
answerRef.current?.select();
|
||||||
}}
|
}}
|
||||||
onSubmit={() => {
|
onSubmit={() => {
|
||||||
submitValue.current = answerRef.current?.value || value;
|
submitValue.current = value;
|
||||||
setShown(false);
|
setShown(false);
|
||||||
}}
|
}}
|
||||||
onHidden={() => {
|
onHidden={() => {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/"))) {
|
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (note.type === "file" && MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime)) {
|
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
color: var(--muted-text-color);
|
color: var(--muted-text-color);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
white-space: pre-line;
|
||||||
|
|
||||||
.tn-icon {
|
.tn-icon {
|
||||||
font-size: 4em;
|
font-size: 4em;
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
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,9 +1,8 @@
|
|||||||
import type { JSX,RefObject } from "preact";
|
|
||||||
import type { CSSProperties } from "preact/compat";
|
|
||||||
import { useEffect } from "preact/hooks";
|
|
||||||
|
|
||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
|
import { useEffect } from "preact/hooks";
|
||||||
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
|
||||||
|
import type { RefObject } from "preact";
|
||||||
|
import type { CSSProperties } from "preact/compat";
|
||||||
import { useSyncedRef } from "./hooks";
|
import { useSyncedRef } from "./hooks";
|
||||||
|
|
||||||
interface NoteAutocompleteProps {
|
interface NoteAutocompleteProps {
|
||||||
@@ -17,111 +16,85 @@ interface NoteAutocompleteProps {
|
|||||||
onChange?: (suggestion: Suggestion | null) => void;
|
onChange?: (suggestion: Suggestion | null) => void;
|
||||||
onTextChange?: (text: string) => void;
|
onTextChange?: (text: string) => void;
|
||||||
onKeyDown?: (e: KeyboardEvent) => void;
|
onKeyDown?: (e: KeyboardEvent) => void;
|
||||||
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
|
||||||
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
|
|
||||||
onBlur?: (newValue: string) => void;
|
onBlur?: (newValue: string) => void;
|
||||||
noteIdChanged?: (noteId: string) => void;
|
noteIdChanged?: (noteId: string) => void;
|
||||||
noteId?: string;
|
noteId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) {
|
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) {
|
||||||
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
|
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const $autoComplete = $(ref.current);
|
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, {
|
note_autocomplete.initNoteAutocomplete($autoComplete, {
|
||||||
...opts,
|
...opts,
|
||||||
container: container?.current
|
container: container?.current
|
||||||
});
|
});
|
||||||
}, [opts, container?.current]);
|
|
||||||
|
|
||||||
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 (onTextChange) {
|
if (onTextChange) {
|
||||||
$autoComplete.on("input", inputListener);
|
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
|
||||||
}
|
}
|
||||||
if (onKeyDown) {
|
if (onKeyDown) {
|
||||||
$autoComplete.on("keydown", keyDownListener);
|
$autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
|
||||||
}
|
}
|
||||||
if (onBlur) {
|
if (onBlur) {
|
||||||
$autoComplete.on("blur", blurListener);
|
$autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
|
||||||
}
|
}
|
||||||
|
}, [opts, container?.current]);
|
||||||
|
|
||||||
return () => {
|
// On change event handlers.
|
||||||
if (onTextChange) {
|
|
||||||
$autoComplete.off("input", inputListener);
|
|
||||||
}
|
|
||||||
if (onKeyDown) {
|
|
||||||
$autoComplete.off("keydown", keyDownListener);
|
|
||||||
}
|
|
||||||
if (onBlur) {
|
|
||||||
$autoComplete.off("blur", blurListener);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [onBlur, onKeyDown, onTextChange]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const $autoComplete = $(ref.current);
|
const $autoComplete = $(ref.current);
|
||||||
if (!(onChange || noteIdChanged)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoCompleteListener = (_e, suggestion) => {
|
if (onChange || noteIdChanged) {
|
||||||
onChange?.(suggestion);
|
const autoCompleteListener = (_e, suggestion) => {
|
||||||
|
onChange?.(suggestion);
|
||||||
|
|
||||||
if (noteIdChanged) {
|
if (noteIdChanged) {
|
||||||
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
const noteId = suggestion?.notePath?.split("/")?.at(-1);
|
||||||
noteIdChanged(noteId);
|
noteIdChanged(noteId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const changeListener = (e) => {
|
const changeListener = (e) => {
|
||||||
if (!ref.current?.value) {
|
if (!ref.current?.value) {
|
||||||
autoCompleteListener(e, null);
|
autoCompleteListener(e, null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$autoComplete
|
|
||||||
.on("autocomplete:noteselected", autoCompleteListener)
|
|
||||||
.on("autocomplete:externallinkselected", autoCompleteListener)
|
|
||||||
.on("autocomplete:commandselected", autoCompleteListener)
|
|
||||||
.on("change", changeListener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
$autoComplete
|
$autoComplete
|
||||||
.off("autocomplete:noteselected", autoCompleteListener)
|
.on("autocomplete:noteselected", autoCompleteListener)
|
||||||
.off("autocomplete:externallinkselected", autoCompleteListener)
|
.on("autocomplete:externallinkselected", autoCompleteListener)
|
||||||
.off("autocomplete:commandselected", autoCompleteListener)
|
.on("autocomplete:commandselected", autoCompleteListener)
|
||||||
.off("change", changeListener);
|
.on("change", changeListener);
|
||||||
};
|
return () => {
|
||||||
}, [onChange, noteIdChanged]);
|
$autoComplete
|
||||||
|
.off("autocomplete:noteselected", autoCompleteListener)
|
||||||
|
.off("autocomplete:externallinkselected", autoCompleteListener)
|
||||||
|
.off("autocomplete:commandselected", autoCompleteListener)
|
||||||
|
.off("change", changeListener);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [opts, container?.current, onChange, noteIdChanged])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current) return;
|
if (!ref.current) return;
|
||||||
const $autoComplete = $(ref.current);
|
const $autoComplete = $(ref.current);
|
||||||
|
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
void $autoComplete.setNote(noteId);
|
$autoComplete.setNote(noteId);
|
||||||
return;
|
} else if (text) {
|
||||||
|
note_autocomplete.setText($autoComplete, text);
|
||||||
|
} else {
|
||||||
|
$autoComplete.setSelectedNotePath("");
|
||||||
|
$autoComplete.autocomplete("val", "");
|
||||||
|
ref.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (text !== undefined) {
|
|
||||||
if (text) {
|
|
||||||
note_autocomplete.setText($autoComplete, text);
|
|
||||||
} else {
|
|
||||||
note_autocomplete.clearText($autoComplete);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
note_autocomplete.clearText($autoComplete);
|
|
||||||
}, [text, noteId]);
|
}, [text, noteId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -130,8 +103,6 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
|
|||||||
id={id}
|
id={id}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="note-autocomplete form-control"
|
className="note-autocomplete form-control"
|
||||||
onKeyDownCapture={onKeyDownCapture}
|
|
||||||
onKeyUpCapture={onKeyUpCapture}
|
|
||||||
placeholder={placeholder ?? t("add_link.search_note")} />
|
placeholder={placeholder ?? t("add_link.search_note")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -50,8 +50,9 @@ body.desktop {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-detail-empty-results .aa-core-panel--contained {
|
.note-detail-empty-results .aa-dropdown-menu {
|
||||||
border: 0;
|
border: var(--bs-border-width) solid var(--bs-border-color);
|
||||||
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-tab-search label {
|
.empty-tab-search label {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import "./File.css";
|
import "./File.css";
|
||||||
|
|
||||||
import FNote from "../../entities/fnote";
|
|
||||||
import { t } from "../../services/i18n";
|
import { t } from "../../services/i18n";
|
||||||
import { getUrlForDownload } from "../../services/open";
|
|
||||||
import Alert from "../react/Alert";
|
import Alert from "../react/Alert";
|
||||||
import { useNoteBlob } from "../react/hooks";
|
import { useNoteBlob } from "../react/hooks";
|
||||||
|
import AudioPreview from "./file/Audio";
|
||||||
import PdfPreview from "./file/Pdf";
|
import PdfPreview from "./file/Pdf";
|
||||||
import VideoPreview from "./file/Video";
|
import VideoPreview from "./file/Video";
|
||||||
import { TypeWidgetProps } from "./type_widget";
|
import { TypeWidgetProps } from "./type_widget";
|
||||||
@@ -43,16 +42,6 @@ function TextPreview({ content }: { content: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AudioPreview({ note }: { note: FNote }) {
|
|
||||||
return (
|
|
||||||
<audio
|
|
||||||
class="audio-preview"
|
|
||||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
|
||||||
controls
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NoPreview() {
|
function NoPreview() {
|
||||||
return (
|
return (
|
||||||
<Alert className="file-preview-not-available" type="info">
|
<Alert className="file-preview-not-available" type="info">
|
||||||
|
|||||||
112
apps/client/src/widgets/type_widgets/file/Audio.tsx
Normal file
112
apps/client/src/widgets/type_widgets/file/Audio.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import FNote from "../../../entities/fnote";
|
||||||
|
import { t } from "../../../services/i18n";
|
||||||
|
import { getUrlForDownload } from "../../../services/open";
|
||||||
|
import Icon from "../../react/Icon";
|
||||||
|
import NoItems from "../../react/NoItems";
|
||||||
|
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
|
||||||
|
|
||||||
|
export default function AudioPreview({ note }: { note: FNote }) {
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const togglePlayback = useCallback(() => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
const onKeyDown = useKeyboardShortcuts(audioRef, togglePlayback);
|
||||||
|
|
||||||
|
useEffect(() => setError(false), [note.noteId]);
|
||||||
|
const onError = useCallback(() => setError(true), []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <NoItems icon="bx bx-volume-mute" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className="audio-preview-wrapper" onKeyDown={onKeyDown} tabIndex={0}>
|
||||||
|
<audio
|
||||||
|
class="audio-preview"
|
||||||
|
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||||
|
ref={audioRef}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
<div className="audio-preview-icon-wrapper">
|
||||||
|
<Icon icon="bx bx-music" className="audio-preview-icon" />
|
||||||
|
</div>
|
||||||
|
<div className="media-preview-controls">
|
||||||
|
<SeekBar mediaRef={audioRef} />
|
||||||
|
|
||||||
|
<div class="media-buttons-row">
|
||||||
|
<div className="left">
|
||||||
|
<PlaybackSpeed mediaRef={audioRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="center">
|
||||||
|
<div className="spacer" />
|
||||||
|
<SkipButton mediaRef={audioRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
|
||||||
|
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
|
||||||
|
<SkipButton mediaRef={audioRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
|
||||||
|
<LoopButton mediaRef={audioRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="right">
|
||||||
|
<VolumeControl mediaRef={audioRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useKeyboardShortcuts(audioRef: MutableRef<HTMLAudioElement | null>, togglePlayback: () => void) {
|
||||||
|
return useCallback((e: KeyboardEvent) => {
|
||||||
|
const audio = audioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case " ":
|
||||||
|
e.preventDefault();
|
||||||
|
togglePlayback();
|
||||||
|
break;
|
||||||
|
case "ArrowLeft":
|
||||||
|
e.preventDefault();
|
||||||
|
audio.currentTime = Math.max(0, audio.currentTime - (e.ctrlKey ? 60 : 10));
|
||||||
|
break;
|
||||||
|
case "ArrowRight":
|
||||||
|
e.preventDefault();
|
||||||
|
audio.currentTime = Math.min(audio.duration, audio.currentTime + (e.ctrlKey ? 60 : 10));
|
||||||
|
break;
|
||||||
|
case "m":
|
||||||
|
case "M":
|
||||||
|
e.preventDefault();
|
||||||
|
audio.muted = !audio.muted;
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
e.preventDefault();
|
||||||
|
audio.volume = Math.min(1, audio.volume + 0.05);
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
e.preventDefault();
|
||||||
|
audio.volume = Math.max(0, audio.volume - 0.05);
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
e.preventDefault();
|
||||||
|
audio.currentTime = 0;
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
e.preventDefault();
|
||||||
|
audio.currentTime = audio.duration;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [ audioRef, togglePlayback ]);
|
||||||
|
}
|
||||||
98
apps/client/src/widgets/type_widgets/file/MediaPlayer.css
Normal file
98
apps/client/src/widgets/type_widgets/file/MediaPlayer.css
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
.media-preview-controls {
|
||||||
|
padding: 1.25em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5em;
|
||||||
|
|
||||||
|
.media-buttons-row {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
flex: 1;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
width: var(--icon-button-size, 32px);
|
||||||
|
height: var(--icon-button-size, 32px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
--icon-button-size: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-seekbar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
|
||||||
|
.media-time {
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-trackbar {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-volume-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25em;
|
||||||
|
|
||||||
|
.media-volume-slider {
|
||||||
|
width: 80px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-dropdown {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.tn-icon {
|
||||||
|
transform: translateY(-10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-speed-label {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
transform: translateY(15%);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-preview-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.audio-preview-icon-wrapper {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 8em;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
220
apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx
Normal file
220
apps/client/src/widgets/type_widgets/file/MediaPlayer.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import "./MediaPlayer.css";
|
||||||
|
|
||||||
|
import { RefObject } from "preact";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { t } from "../../../services/i18n";
|
||||||
|
import ActionButton from "../../react/ActionButton";
|
||||||
|
import Dropdown from "../../react/Dropdown";
|
||||||
|
import Icon from "../../react/Icon";
|
||||||
|
|
||||||
|
export function SeekBar({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
|
||||||
|
const onTimeUpdate = () => setCurrentTime(media.currentTime);
|
||||||
|
const onDurationChange = () => setDuration(media.duration);
|
||||||
|
|
||||||
|
media.addEventListener("timeupdate", onTimeUpdate);
|
||||||
|
media.addEventListener("durationchange", onDurationChange);
|
||||||
|
return () => {
|
||||||
|
media.removeEventListener("timeupdate", onTimeUpdate);
|
||||||
|
media.removeEventListener("durationchange", onDurationChange);
|
||||||
|
};
|
||||||
|
}, [ mediaRef ]);
|
||||||
|
|
||||||
|
const onSeek = (e: Event) => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
media.currentTime = parseFloat((e.target as HTMLInputElement).value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="media-seekbar-row">
|
||||||
|
<span class="media-time">{formatTime(currentTime)}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="media-trackbar"
|
||||||
|
min={0}
|
||||||
|
max={duration || 0}
|
||||||
|
step={0.1}
|
||||||
|
value={currentTime}
|
||||||
|
onInput={onSeek}
|
||||||
|
/>
|
||||||
|
<span class="media-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number): string {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlayPauseButton({ playing, togglePlayback }: {
|
||||||
|
playing: boolean,
|
||||||
|
togglePlayback: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
className="play-button"
|
||||||
|
icon={playing ? "bx bx-pause" : "bx bx-play"}
|
||||||
|
text={playing ? t("media.pause") : t("media.play")}
|
||||||
|
onClick={togglePlayback}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||||
|
const [volume, setVolume] = useState(() => mediaRef.current?.volume ?? 1);
|
||||||
|
const [muted, setMuted] = useState(() => mediaRef.current?.muted ?? false);
|
||||||
|
|
||||||
|
// Sync state when the media element changes volume externally.
|
||||||
|
useEffect(() => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
|
||||||
|
setVolume(media.volume);
|
||||||
|
setMuted(media.muted);
|
||||||
|
|
||||||
|
const onVolumeChange = () => {
|
||||||
|
setVolume(media.volume);
|
||||||
|
setMuted(media.muted);
|
||||||
|
};
|
||||||
|
media.addEventListener("volumechange", onVolumeChange);
|
||||||
|
return () => media.removeEventListener("volumechange", onVolumeChange);
|
||||||
|
}, [ mediaRef ]);
|
||||||
|
|
||||||
|
const onVolumeChange = (e: Event) => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||||
|
media.volume = val;
|
||||||
|
setVolume(val);
|
||||||
|
if (val > 0 && media.muted) {
|
||||||
|
media.muted = false;
|
||||||
|
setMuted(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
media.muted = !media.muted;
|
||||||
|
setMuted(media.muted);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="media-volume-row">
|
||||||
|
<ActionButton
|
||||||
|
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
|
||||||
|
text={muted ? t("media.unmute") : t("media.mute")}
|
||||||
|
onClick={toggleMute}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="media-volume-slider"
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
value={muted ? 0 : volume}
|
||||||
|
onInput={onVolumeChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkipButton({ mediaRef, seconds, icon, text }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement>, seconds: number, icon: string, text: string }) {
|
||||||
|
const skip = () => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
media.currentTime = Math.max(0, Math.min(media.duration, media.currentTime + seconds));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionButton icon={icon} text={text} onClick={skip} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoopButton({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||||
|
const [loop, setLoop] = useState(() => mediaRef.current?.loop ?? false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
setLoop(media.loop);
|
||||||
|
|
||||||
|
const observer = new MutationObserver(() => setLoop(media.loop));
|
||||||
|
observer.observe(media, { attributes: true, attributeFilter: ["loop"] });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [ mediaRef ]);
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
media.loop = !media.loop;
|
||||||
|
setLoop(media.loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
className={loop ? "active" : ""}
|
||||||
|
icon="bx bx-repeat"
|
||||||
|
text={loop ? t("media.disable-loop") : t("media.loop")}
|
||||||
|
onClick={toggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
|
||||||
|
|
||||||
|
export function PlaybackSpeed({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
|
||||||
|
const [speed, setSpeed] = useState(() => mediaRef.current?.playbackRate ?? 1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
|
||||||
|
setSpeed(media.playbackRate);
|
||||||
|
|
||||||
|
const onRateChange = () => setSpeed(media.playbackRate);
|
||||||
|
media.addEventListener("ratechange", onRateChange);
|
||||||
|
return () => media.removeEventListener("ratechange", onRateChange);
|
||||||
|
}, [ mediaRef ]);
|
||||||
|
|
||||||
|
const selectSpeed = (rate: number) => {
|
||||||
|
const media = mediaRef.current;
|
||||||
|
if (!media) return;
|
||||||
|
media.playbackRate = rate;
|
||||||
|
setSpeed(rate);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
iconAction
|
||||||
|
hideToggleArrow
|
||||||
|
buttonClassName="speed-dropdown"
|
||||||
|
text={<>
|
||||||
|
<Icon icon="bx bx-tachometer" />
|
||||||
|
<span class="media-speed-label">{speed}x</span>
|
||||||
|
</>}
|
||||||
|
title={t("media.playback-speed")}
|
||||||
|
>
|
||||||
|
{PLAYBACK_SPEEDS.map((rate) => (
|
||||||
|
<li key={rate}>
|
||||||
|
<button
|
||||||
|
class={`dropdown-item ${rate === speed ? "active" : ""}`}
|
||||||
|
onClick={() => selectSpeed(rate)}
|
||||||
|
>
|
||||||
|
{rate}x
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,102 +13,23 @@
|
|||||||
&.controls-hidden {
|
&.controls-hidden {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.video-preview-controls {
|
.media-preview-controls {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-preview-controls {
|
.media-preview-controls {
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
padding: 1.25em;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5em;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
color: white;
|
|
||||||
--icon-button-hover-color: white;
|
--icon-button-hover-color: white;
|
||||||
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
|
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: opacity 300ms ease;
|
transition: opacity 300ms ease;
|
||||||
|
position: absolute;
|
||||||
.video-buttons-row {
|
bottom: 0;
|
||||||
display: flex;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
> * {
|
background: rgba(0, 0, 0, 0.5);
|
||||||
flex: 1;
|
backdrop-filter: blur(6px);
|
||||||
align-items: center;
|
color: white;
|
||||||
gap: 0.5em;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spacer {
|
|
||||||
width: var(--icon-button-size, 32px);
|
|
||||||
height: var(--icon-button-size, 32px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.play-button {
|
|
||||||
--icon-button-size: 48px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-seekbar-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-trackbar {
|
|
||||||
flex: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-time {
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-volume-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-volume-slider {
|
|
||||||
width: 80px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-dropdown {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.tn-icon {
|
|
||||||
transform: translateY(-10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.video-speed-label {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
transform: translateY(15%);
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import "./Video.css";
|
import "./Video.css";
|
||||||
|
|
||||||
import { RefObject } from "preact";
|
import { RefObject } from "preact";
|
||||||
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
|
||||||
|
|
||||||
import FNote from "../../../entities/fnote";
|
import FNote from "../../../entities/fnote";
|
||||||
import { t } from "../../../services/i18n";
|
import { t } from "../../../services/i18n";
|
||||||
import { getUrlForDownload } from "../../../services/open";
|
import { getUrlForDownload } from "../../../services/open";
|
||||||
import ActionButton from "../../react/ActionButton";
|
import ActionButton from "../../react/ActionButton";
|
||||||
import Dropdown from "../../react/Dropdown";
|
|
||||||
import Icon from "../../react/Icon";
|
|
||||||
import NoItems from "../../react/NoItems";
|
import NoItems from "../../react/NoItems";
|
||||||
|
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
|
||||||
function formatTime(seconds: number): string {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = Math.floor(seconds % 60);
|
|
||||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AUTO_HIDE_DELAY = 3000;
|
const AUTO_HIDE_DELAY = 3000;
|
||||||
|
|
||||||
@@ -40,11 +33,56 @@ export default function VideoPreview({ note }: { note: FNote }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onVideoClick = useCallback((e: MouseEvent) => {
|
const onVideoClick = useCallback((e: MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).closest(".video-preview-controls")) return;
|
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
|
||||||
togglePlayback();
|
togglePlayback();
|
||||||
}, [togglePlayback]);
|
}, [togglePlayback]);
|
||||||
|
|
||||||
const onKeyDown = useCallback((e: KeyboardEvent) => {
|
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
class="video-preview"
|
||||||
|
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
||||||
|
datatype={note?.mime}
|
||||||
|
onPlay={() => setPlaying(true)}
|
||||||
|
onPause={() => setPlaying(false)}
|
||||||
|
onError={onError}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="media-preview-controls">
|
||||||
|
<SeekBar mediaRef={videoRef} />
|
||||||
|
<div class="media-buttons-row">
|
||||||
|
<div className="left">
|
||||||
|
<PlaybackSpeed mediaRef={videoRef} />
|
||||||
|
<RotateButton videoRef={videoRef} />
|
||||||
|
</div>
|
||||||
|
<div className="center">
|
||||||
|
<div className="spacer" />
|
||||||
|
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
|
||||||
|
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
|
||||||
|
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
|
||||||
|
<LoopButton mediaRef={videoRef} />
|
||||||
|
</div>
|
||||||
|
<div className="right">
|
||||||
|
<VolumeControl mediaRef={videoRef} />
|
||||||
|
<ZoomToFitButton videoRef={videoRef} />
|
||||||
|
<PictureInPictureButton videoRef={videoRef} />
|
||||||
|
<FullscreenButton targetRef={wrapperRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useKeyboardShortcuts(videoRef: MutableRef<HTMLVideoElement | null>, wrapperRef: MutableRef<HTMLDivElement | null>, togglePlayback: () => void, flashControls: () => void) {
|
||||||
|
return useCallback((e: KeyboardEvent) => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
|
|
||||||
@@ -100,48 +138,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
|
|||||||
flashControls();
|
flashControls();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [togglePlayback, flashControls]);
|
}, [ wrapperRef, videoRef, togglePlayback, flashControls ]);
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <NoItems icon="bx bx-video-off" text={t("video.unsupported-format")} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
|
|
||||||
<video
|
|
||||||
ref={videoRef}
|
|
||||||
class="video-preview"
|
|
||||||
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
|
|
||||||
datatype={note?.mime}
|
|
||||||
onPlay={() => setPlaying(true)}
|
|
||||||
onPause={() => setPlaying(false)}
|
|
||||||
onError={onError}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="video-preview-controls">
|
|
||||||
<SeekBar videoRef={videoRef} />
|
|
||||||
<div class="video-buttons-row">
|
|
||||||
<div className="left">
|
|
||||||
<PlaybackSpeed videoRef={videoRef} />
|
|
||||||
<RotateButton videoRef={videoRef} />
|
|
||||||
</div>
|
|
||||||
<div className="center">
|
|
||||||
<div className="spacer" />
|
|
||||||
<SkipButton videoRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("video.back-10s")} />
|
|
||||||
<PlayPauseButton videoRef={videoRef} playing={playing} />
|
|
||||||
<SkipButton videoRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("video.forward-30s")} />
|
|
||||||
<LoopButton videoRef={videoRef} />
|
|
||||||
</div>
|
|
||||||
<div className="right">
|
|
||||||
<VolumeControl videoRef={videoRef} />
|
|
||||||
<ZoomToFitButton videoRef={videoRef} />
|
|
||||||
<PictureInPictureButton videoRef={videoRef} />
|
|
||||||
<FullscreenButton targetRef={wrapperRef} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
|
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
|
||||||
@@ -153,7 +150,7 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
|
|||||||
if (videoRef.current && !videoRef.current.paused) {
|
if (videoRef.current && !videoRef.current.paused) {
|
||||||
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
|
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [ videoRef]);
|
||||||
|
|
||||||
const onMouseMove = useCallback(() => {
|
const onMouseMove = useCallback(() => {
|
||||||
setVisible(true);
|
setVisible(true);
|
||||||
@@ -174,219 +171,6 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
|
|||||||
return { visible, onMouseMove, flash: onMouseMove };
|
return { visible, onMouseMove, flash: onMouseMove };
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayPauseButton({ videoRef, playing }: { videoRef: RefObject<HTMLVideoElement>, playing: boolean }) {
|
|
||||||
const togglePlayback = () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
if (video.paused) {
|
|
||||||
video.play();
|
|
||||||
} else {
|
|
||||||
video.pause();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionButton
|
|
||||||
className="play-button"
|
|
||||||
icon={playing ? "bx bx-pause" : "bx bx-play"}
|
|
||||||
text={playing ? t("video.pause") : t("video.play")}
|
|
||||||
onClick={togglePlayback}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SkipButton({ videoRef, seconds, icon, text }: { videoRef: RefObject<HTMLVideoElement>, seconds: number, icon: string, text: string }) {
|
|
||||||
const skip = () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + seconds));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionButton icon={icon} text={text} onClick={skip} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SeekBar({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
const onTimeUpdate = () => setCurrentTime(video.currentTime);
|
|
||||||
const onDurationChange = () => setDuration(video.duration);
|
|
||||||
|
|
||||||
video.addEventListener("timeupdate", onTimeUpdate);
|
|
||||||
video.addEventListener("durationchange", onDurationChange);
|
|
||||||
return () => {
|
|
||||||
video.removeEventListener("timeupdate", onTimeUpdate);
|
|
||||||
video.removeEventListener("durationchange", onDurationChange);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onSeek = (e: Event) => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
video.currentTime = parseFloat((e.target as HTMLInputElement).value);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="video-seekbar-row">
|
|
||||||
<span class="video-time">{formatTime(currentTime)}</span>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
class="video-trackbar"
|
|
||||||
min={0}
|
|
||||||
max={duration || 0}
|
|
||||||
step={0.1}
|
|
||||||
value={currentTime}
|
|
||||||
onInput={onSeek}
|
|
||||||
/>
|
|
||||||
<span class="video-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function VolumeControl({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
|
||||||
const [volume, setVolume] = useState(() => videoRef.current?.volume ?? 1);
|
|
||||||
const [muted, setMuted] = useState(() => videoRef.current?.muted ?? false);
|
|
||||||
|
|
||||||
// Sync state when the video element changes volume externally.
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
setVolume(video.volume);
|
|
||||||
setMuted(video.muted);
|
|
||||||
|
|
||||||
const onVolumeChange = () => {
|
|
||||||
setVolume(video.volume);
|
|
||||||
setMuted(video.muted);
|
|
||||||
};
|
|
||||||
video.addEventListener("volumechange", onVolumeChange);
|
|
||||||
return () => video.removeEventListener("volumechange", onVolumeChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onVolumeChange = (e: Event) => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
|
||||||
video.volume = val;
|
|
||||||
setVolume(val);
|
|
||||||
if (val > 0 && video.muted) {
|
|
||||||
video.muted = false;
|
|
||||||
setMuted(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
video.muted = !video.muted;
|
|
||||||
setMuted(video.muted);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="video-volume-row">
|
|
||||||
<ActionButton
|
|
||||||
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
|
|
||||||
text={muted ? t("video.unmute") : t("video.mute")}
|
|
||||||
onClick={toggleMute}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
class="video-volume-slider"
|
|
||||||
min={0}
|
|
||||||
max={1}
|
|
||||||
step={0.05}
|
|
||||||
value={muted ? 0 : volume}
|
|
||||||
onInput={onVolumeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
|
|
||||||
|
|
||||||
function PlaybackSpeed({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
|
||||||
const [speed, setSpeed] = useState(() => videoRef.current?.playbackRate ?? 1);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
|
|
||||||
setSpeed(video.playbackRate);
|
|
||||||
|
|
||||||
const onRateChange = () => setSpeed(video.playbackRate);
|
|
||||||
video.addEventListener("ratechange", onRateChange);
|
|
||||||
return () => video.removeEventListener("ratechange", onRateChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const selectSpeed = (rate: number) => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
video.playbackRate = rate;
|
|
||||||
setSpeed(rate);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
iconAction
|
|
||||||
hideToggleArrow
|
|
||||||
buttonClassName="speed-dropdown"
|
|
||||||
text={<>
|
|
||||||
<Icon icon="bx bx-tachometer" />
|
|
||||||
<span class="video-speed-label">{speed}x</span>
|
|
||||||
</>}
|
|
||||||
title={t("video.playback-speed")}
|
|
||||||
>
|
|
||||||
{PLAYBACK_SPEEDS.map((rate) => (
|
|
||||||
<li key={rate}>
|
|
||||||
<button
|
|
||||||
class={`dropdown-item ${rate === speed ? "active" : ""}`}
|
|
||||||
onClick={() => selectSpeed(rate)}
|
|
||||||
>
|
|
||||||
{rate}x
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LoopButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
|
||||||
const [loop, setLoop] = useState(() => videoRef.current?.loop ?? false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
setLoop(video.loop);
|
|
||||||
|
|
||||||
const observer = new MutationObserver(() => setLoop(video.loop));
|
|
||||||
observer.observe(video, { attributes: true, attributeFilter: ["loop"] });
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!video) return;
|
|
||||||
video.loop = !video.loop;
|
|
||||||
setLoop(video.loop);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ActionButton
|
|
||||||
className={loop ? "active" : ""}
|
|
||||||
icon="bx bx-repeat"
|
|
||||||
text={loop ? t("video.disable-loop") : t("video.loop")}
|
|
||||||
onClick={toggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
||||||
const [rotation, setRotation] = useState(0);
|
const [rotation, setRotation] = useState(0);
|
||||||
|
|
||||||
@@ -414,7 +198,7 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
|
|||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon="bx bx-rotate-right"
|
icon="bx bx-rotate-right"
|
||||||
text={t("video.rotate")}
|
text={t("media.rotate")}
|
||||||
onClick={rotate}
|
onClick={rotate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -435,7 +219,7 @@ function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }
|
|||||||
<ActionButton
|
<ActionButton
|
||||||
className={fitted ? "active" : ""}
|
className={fitted ? "active" : ""}
|
||||||
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
|
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
|
||||||
text={fitted ? t("video.zoom-reset") : t("video.zoom-to-fit")}
|
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -460,7 +244,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
|
|||||||
video.removeEventListener("enterpictureinpicture", onEnter);
|
video.removeEventListener("enterpictureinpicture", onEnter);
|
||||||
video.removeEventListener("leavepictureinpicture", onLeave);
|
video.removeEventListener("leavepictureinpicture", onLeave);
|
||||||
};
|
};
|
||||||
}, [supported]);
|
}, [ videoRef, supported ]);
|
||||||
|
|
||||||
if (!supported) return null;
|
if (!supported) return null;
|
||||||
|
|
||||||
@@ -478,7 +262,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
|
|||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={active ? "bx bx-exit" : "bx bx-window-open"}
|
icon={active ? "bx bx-exit" : "bx bx-window-open"}
|
||||||
text={active ? t("video.exit-picture-in-picture") : t("video.picture-in-picture")}
|
text={active ? t("media.exit-picture-in-picture") : t("media.picture-in-picture")}
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -507,7 +291,7 @@ function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> })
|
|||||||
return (
|
return (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
|
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
|
||||||
text={isFullscreen ? t("video.exit-fullscreen") : t("video.fullscreen")}
|
text={isFullscreen ? t("media.exit-fullscreen") : t("media.fullscreen")}
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { expect,test } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
|
||||||
import App from "../support/app";
|
import App from "../support/app";
|
||||||
|
|
||||||
const TEXT_NOTE_TITLE = "Text notes";
|
const TEXT_NOTE_TITLE = "Text notes";
|
||||||
@@ -33,7 +32,8 @@ test("Open the note in the correct split pane", async ({ page, context }) => {
|
|||||||
await noteContent.focus();
|
await noteContent.focus();
|
||||||
|
|
||||||
// Click the search result in the second split.
|
// Click the search result in the second split.
|
||||||
await app.getNoteAutocompleteSuggestion(resultsSelector, CODE_NOTE_TITLE).click();
|
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
|
||||||
|
.nth(1).click();
|
||||||
|
|
||||||
await expect(split2).toContainText(CODE_NOTE_TITLE);
|
await expect(split2).toContainText(CODE_NOTE_TITLE);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ export default class App {
|
|||||||
readonly currentNoteSplitContent: Locator;
|
readonly currentNoteSplitContent: Locator;
|
||||||
readonly sidebar: Locator;
|
readonly sidebar: Locator;
|
||||||
private isMobile: boolean = false;
|
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) {
|
constructor(page: Page, context: BrowserContext) {
|
||||||
this.page = page;
|
this.page = page;
|
||||||
@@ -77,19 +76,12 @@ export default class App {
|
|||||||
|
|
||||||
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
|
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
|
||||||
await expect(resultsSelector).toContainText(noteTitle);
|
await expect(resultsSelector).toContainText(noteTitle);
|
||||||
const suggestionSelector = resultsSelector
|
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
|
||||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
|
||||||
.first();
|
|
||||||
await expect(suggestionSelector).toContainText(noteTitle);
|
await expect(suggestionSelector).toContainText(noteTitle);
|
||||||
await suggestionSelector.click();
|
await suggestionSelector.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
getNoteAutocompleteSuggestion(resultsContainer: Locator, noteTitle: string) {
|
|
||||||
return resultsContainer
|
|
||||||
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
|
|
||||||
.first();
|
|
||||||
}
|
|
||||||
|
|
||||||
async goToSettings() {
|
async goToSettings() {
|
||||||
await this.page.locator(".launcher-button.bx-cog").click();
|
await this.page.locator(".launcher-button.bx-cog").click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@
|
|||||||
"html": "1.0.0",
|
"html": "1.0.0",
|
||||||
"html2plaintext": "2.1.4",
|
"html2plaintext": "2.1.4",
|
||||||
"http-proxy-agent": "7.0.2",
|
"http-proxy-agent": "7.0.2",
|
||||||
"https-proxy-agent": "7.0.6",
|
"https-proxy-agent": "8.0.0",
|
||||||
"i18next": "25.8.17",
|
"i18next": "25.8.17",
|
||||||
"i18next-fs-backend": "2.6.1",
|
"i18next-fs-backend": "2.6.1",
|
||||||
"image-type": "6.0.0",
|
"image-type": "6.0.0",
|
||||||
|
|||||||
@@ -71,27 +71,6 @@ function getAttributeNames(type: string, nameLike: string) {
|
|||||||
[type, `%${nameLike}%`]
|
[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) {
|
for (const attr of BUILTIN_ATTRIBUTES) {
|
||||||
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
|
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
|
||||||
names.push(attr.name);
|
names.push(attr.name);
|
||||||
|
|||||||
@@ -197,5 +197,12 @@
|
|||||||
"description": "Trilium Notes는 간편한 접근 및 관리를 위해 유료 서비스인 PikaPods에서 호스팅할 수 있습니다. Trilium 팀과 직접 제휴되어있지는 않습니다.",
|
"description": "Trilium Notes는 간편한 접근 및 관리를 위해 유료 서비스인 PikaPods에서 호스팅할 수 있습니다. Trilium 팀과 직접 제휴되어있지는 않습니다.",
|
||||||
"download_pikapod": "PikaPods에서 설치하기",
|
"download_pikapod": "PikaPods에서 설치하기",
|
||||||
"download_triliumcc": "또는 trilium.cc를 참조하세요"
|
"download_triliumcc": "또는 trilium.cc를 참조하세요"
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"title": "리소스",
|
||||||
|
"icon_packs": "아이콘 팩",
|
||||||
|
"icon_packs_intro": "아이콘 팩을 사용하여 노트에 사용할 수 있는 아이콘 종류를 늘려보세요. 아이콘 팩에 대한 자세한 내용은 <DocumentationLink>공식 문서</DocumentationLink>를 참조하세요.",
|
||||||
|
"download": "다운로드",
|
||||||
|
"website": "웹사이트"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,9 @@
|
|||||||
"canvas_description": "Розташовуйте фігури, зображення та текст на нескінченному полотні, використовуючи ту саму технологію, що й excalidraw.com. Ідеально підходить для діаграм, ескізів та візуального планування.",
|
"canvas_description": "Розташовуйте фігури, зображення та текст на нескінченному полотні, використовуючи ту саму технологію, що й excalidraw.com. Ідеально підходить для діаграм, ескізів та візуального планування.",
|
||||||
"mermaid_description": "Створюйте діаграми, такі як блок-схеми, діаграми класів та послідовностей, діаграми Ганта та багато іншого, використовуючи синтаксис Mermaid.",
|
"mermaid_description": "Створюйте діаграми, такі як блок-схеми, діаграми класів та послідовностей, діаграми Ганта та багато іншого, використовуючи синтаксис Mermaid.",
|
||||||
"others_list": "та інші: <0>карта нотаток</0>, <1>карта зв'язків</1>, <2>збережені пошуки</2>, <3>візуалізація нотаток</3> та <4>веб-перегляди</4>.",
|
"others_list": "та інші: <0>карта нотаток</0>, <1>карта зв'язків</1>, <2>збережені пошуки</2>, <3>візуалізація нотаток</3> та <4>веб-перегляди</4>.",
|
||||||
"mermaid_title": "Mermaid діаграми"
|
"mermaid_title": "Mermaid діаграми",
|
||||||
|
"mindmap_title": "Карта думок",
|
||||||
|
"mindmap_description": "Візуально упорядкуйте свої думки або проведіть мозковий штурм."
|
||||||
},
|
},
|
||||||
"extensibility_benefits": {
|
"extensibility_benefits": {
|
||||||
"title": "Спільне використання та розширюваність",
|
"title": "Спільне використання та розширюваність",
|
||||||
@@ -59,7 +61,9 @@
|
|||||||
"share_title": "Діліться нотатками в Інтернеті",
|
"share_title": "Діліться нотатками в Інтернеті",
|
||||||
"share_description": "Якщо у Вас є сервер, Ви можете використати його, щоб поділитися частиною своїх нотаток з іншими людьми.",
|
"share_description": "Якщо у Вас є сервер, Ви можете використати його, щоб поділитися частиною своїх нотаток з іншими людьми.",
|
||||||
"api_title": "REST API",
|
"api_title": "REST API",
|
||||||
"api_description": "Взаємодійте з Trilium програмно, використовуючи його вбудований REST API."
|
"api_description": "Взаємодійте з Trilium програмно, використовуючи його вбудований REST API.",
|
||||||
|
"scripting_title": "Розширений скриптинг",
|
||||||
|
"scripting_description": "Створюйте власні інтеграції в Trilium за допомогою користувацьких віджетів або серверної логіки."
|
||||||
},
|
},
|
||||||
"collections": {
|
"collections": {
|
||||||
"title": "Колекції",
|
"title": "Колекції",
|
||||||
@@ -108,7 +112,8 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"get-started": "Почати",
|
"get-started": "Почати",
|
||||||
"documentation": "Документація",
|
"documentation": "Документація",
|
||||||
"support-us": "Підтримайте нас"
|
"support-us": "Підтримайте нас",
|
||||||
|
"resources": "Ресурси"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright_and_the": " і ",
|
"copyright_and_the": " і ",
|
||||||
@@ -148,7 +153,8 @@
|
|||||||
"description_arm64": "Сумісний з пристроями ARM (наприклад, з Qualcomm Snapdragon).",
|
"description_arm64": "Сумісний з пристроями ARM (наприклад, з Qualcomm Snapdragon).",
|
||||||
"quick_start": "Щоб встановити через Winget:",
|
"quick_start": "Щоб встановити через Winget:",
|
||||||
"download_exe": "Завантажити інсталятор (.exe)",
|
"download_exe": "Завантажити інсталятор (.exe)",
|
||||||
"download_zip": "Портативний (.zip)"
|
"download_zip": "Портативний (.zip)",
|
||||||
|
"download_scoop": "Scoop"
|
||||||
},
|
},
|
||||||
"download_helper_desktop_linux": {
|
"download_helper_desktop_linux": {
|
||||||
"title_x64": "Linux 64-bit",
|
"title_x64": "Linux 64-bit",
|
||||||
@@ -159,23 +165,44 @@
|
|||||||
"download_deb": ".deb",
|
"download_deb": ".deb",
|
||||||
"download_rpm": ".rpm",
|
"download_rpm": ".rpm",
|
||||||
"download_flatpak": ".flatpak",
|
"download_flatpak": ".flatpak",
|
||||||
"download_nixpkgs": "nixpkgs"
|
"download_nixpkgs": "nixpkgs",
|
||||||
|
"download_zip": "Portable (.zip)",
|
||||||
|
"download_aur": "AUR"
|
||||||
},
|
},
|
||||||
"download_helper_desktop_macos": {
|
"download_helper_desktop_macos": {
|
||||||
"title_x64": "macOS для Intel",
|
"title_x64": "macOS для Intel",
|
||||||
"title_arm64": "macOS для Apple Silicon",
|
"title_arm64": "macOS для Apple Silicon",
|
||||||
"quick_start": "Для того, щоб встановити за допомогою Homebrew:",
|
"quick_start": "Для того, щоб встановити за допомогою Homebrew:",
|
||||||
"download_homebrew_cask": "Homebrew Cask"
|
"download_homebrew_cask": "Homebrew Cask",
|
||||||
|
"description_x64": "Для комп’ютерів Mac на базі Intel з macOS Monterey або пізнішої версії.",
|
||||||
|
"description_arm64": "Для комп'ютерів Apple Silicon Mac, таких як ті, що мають чіпи M1 та M2.",
|
||||||
|
"download_dmg": "Завантажити інсталятор (.dmg)",
|
||||||
|
"download_zip": "Portable (.zip)"
|
||||||
},
|
},
|
||||||
"download_helper_server_docker": {
|
"download_helper_server_docker": {
|
||||||
"download_dockerhub": "Docker Hub",
|
"download_dockerhub": "Docker Hub",
|
||||||
"download_ghcr": "ghcr.io"
|
"download_ghcr": "ghcr.io",
|
||||||
|
"title": "Self-hosted using Docker",
|
||||||
|
"description": "Легке розгортання на Windows, Linux або macOS за допомогою контейнера Docker."
|
||||||
},
|
},
|
||||||
"download_helper_server_linux": {
|
"download_helper_server_linux": {
|
||||||
"download_tar_x64": "x64 (.tar.xz)",
|
"download_tar_x64": "x64 (.tar.xz)",
|
||||||
"download_tar_arm64": "ARM (.tar.xz)"
|
"download_tar_arm64": "ARM (.tar.xz)",
|
||||||
|
"title": "Self-hosted on Linux",
|
||||||
|
"description": "Розгорніть Trilium Notes на власному сервері або VPS, сумісному з більшістю дистрибутивів.",
|
||||||
|
"download_nixos": "NixOS module"
|
||||||
},
|
},
|
||||||
"download_helper_server_hosted": {
|
"download_helper_server_hosted": {
|
||||||
"title": "Платний хостинг"
|
"title": "Платний хостинг",
|
||||||
|
"description": "Нотатки Trilium розміщені на PikaPods, платному сервісі для легкого доступу та керування. Не пов'язаний безпосередньо з командою Trilium.",
|
||||||
|
"download_pikapod": "Налаштування на PikaPods",
|
||||||
|
"download_triliumcc": "Або див. trilium.cc"
|
||||||
|
},
|
||||||
|
"resources": {
|
||||||
|
"title": "Ресурси",
|
||||||
|
"icon_packs": "Пакети піктограм",
|
||||||
|
"icon_packs_intro": "Розширте вибір доступних піктограм для ваших нотаток за допомогою пакету піктограм. Щоб отримати докладнішу інформацію про пакети піктограм, див. <DocumentationLink>офіційну документацію</DocumentationLink>.",
|
||||||
|
"download": "Завантажити",
|
||||||
|
"website": "Вебсайт"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
docs/README-ko.md
vendored
28
docs/README-ko.md
vendored
@@ -263,23 +263,19 @@ docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/De
|
|||||||
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 텍스트 노트의 시각적 편집기입니다. 프리미엄
|
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 텍스트 노트의 시각적 편집기입니다. 프리미엄
|
||||||
기능을 제공해주셔서 감사합니다.
|
기능을 제공해주셔서 감사합니다.
|
||||||
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 수많은 언어를 지원하는 코드 편집기.
|
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 수많은 언어를 지원하는 코드 편집기.
|
||||||
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
|
* [Excalidraw](https://github.com/excalidraw/excalidraw) - Canvas 노트에서 사용되는 무한
|
||||||
whiteboard used in Canvas notes.
|
화이트보드입니다.
|
||||||
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
|
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - 마인드맵 기능을 제공합니다.
|
||||||
mind map functionality.
|
* [Leaflet](https://github.com/Leaflet/Leaflet) - 지리 지도를 렌더링 합니다.
|
||||||
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical
|
* [Tabulator](https://github.com/olifolkerd/tabulator) - 컬렉션에서 사용되는 인터랙티브
|
||||||
maps.
|
테이블입니다.
|
||||||
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive
|
* [FancyTree](https://github.com/mar10/fancytree) - 독보적으로 기능이 풍부한 트리 라이브러리입니다.
|
||||||
table used in collections.
|
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 시각적 연결 라이브러리입니다. [관계
|
||||||
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library
|
맵](https://docs.triliumnotes.org/user-guide/note-types/relation-map) 과 [링크
|
||||||
without real competition.
|
맵](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)에
|
||||||
* [jsPlumb](https://github.com/jsplumb/jsplumb) - visual connectivity library.
|
사용됩니다
|
||||||
Used in [relation
|
|
||||||
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
|
|
||||||
[link
|
|
||||||
maps](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)
|
|
||||||
|
|
||||||
## 🤝 Support
|
## 🤝 후원
|
||||||
|
|
||||||
Trilium is built and maintained with [hundreds of hours of
|
Trilium is built and maintained with [hundreds of hours of
|
||||||
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your
|
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your
|
||||||
|
|||||||
46
docs/README-uk.md
vendored
46
docs/README-uk.md
vendored
@@ -95,8 +95,8 @@ Trilium Notes — це безкоштовний кросплатформний
|
|||||||
безпечнішого входу
|
безпечнішого входу
|
||||||
* [Синхронізація](https://docs.triliumnotes.org/user-guide/setup/synchronization)
|
* [Синхронізація](https://docs.triliumnotes.org/user-guide/setup/synchronization)
|
||||||
із власним сервером синхронізації
|
із власним сервером синхронізації
|
||||||
* there are [3rd party services for hosting synchronisation
|
* існують [сторонні сервіси для розміщення сервера
|
||||||
server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
|
синхронізації](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
|
||||||
* [Спільне
|
* [Спільне
|
||||||
використання](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing)
|
використання](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing)
|
||||||
(публікація) нотаток у загальнодоступному інтернеті
|
(публікація) нотаток у загальнодоступному інтернеті
|
||||||
@@ -105,10 +105,11 @@ Trilium Notes — це безкоштовний кросплатформний
|
|||||||
з деталізацією для кожної нотатки
|
з деталізацією для кожної нотатки
|
||||||
* Створення ескізних схем на основі [Excalidraw](https://excalidraw.com/) (тип
|
* Створення ескізних схем на основі [Excalidraw](https://excalidraw.com/) (тип
|
||||||
нотатки "полотно")
|
нотатки "полотно")
|
||||||
* [Relation
|
* [Карти
|
||||||
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
|
зв'язків](https://docs.triliumnotes.org/user-guide/note-types/relation-map) та
|
||||||
[note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map)
|
[карти
|
||||||
for visualizing notes and their relations
|
нотаток/посилань](https://docs.triliumnotes.org/user-guide/note-types/note-map)
|
||||||
|
для візуалізації нотаток та їх зв'язків
|
||||||
* Інтелект-карти, засновані на [Mind Elixir](https://docs.mind-elixir.com/)
|
* Інтелект-карти, засновані на [Mind Elixir](https://docs.mind-elixir.com/)
|
||||||
* [Геокарти](https://docs.triliumnotes.org/user-guide/collections/geomap) з
|
* [Геокарти](https://docs.triliumnotes.org/user-guide/collections/geomap) з
|
||||||
географічними позначками та GPX-треками
|
географічними позначками та GPX-треками
|
||||||
@@ -148,19 +149,18 @@ TriliumNext:
|
|||||||
надав репозиторій Trilium спільнотному проекту, який знаходиться за адресою
|
надав репозиторій Trilium спільнотному проекту, який знаходиться за адресою
|
||||||
https://github.com/TriliumNext
|
https://github.com/TriliumNext
|
||||||
|
|
||||||
### ⬆️Migrating from Zadam/Trilium?
|
### ⬆️Переходите із Zadam/Trilium?
|
||||||
|
|
||||||
There are no special migration steps to migrate from a zadam/Trilium instance to
|
Немає жодних спеціальних кроків для міграції з екземпляра zadam/Trilium до
|
||||||
a TriliumNext/Trilium instance. Simply [install
|
екземпляра TriliumNext/Trilium. Просто [встановіть
|
||||||
TriliumNext/Trilium](#-installation) as usual and it will use your existing
|
TriliumNext/Trilium](#-installation) як завжди, і він використовуватиме вашу
|
||||||
database.
|
існуючу базу даних.
|
||||||
|
|
||||||
Versions up to and including
|
Версії до [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4)
|
||||||
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are
|
включно сумісні з останньою версією zadam/trilium
|
||||||
compatible with the latest zadam/trilium version of
|
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Будь-які
|
||||||
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later
|
пізніші версії TriliumNext/Trilium мають збільшені версії синхронізації, що
|
||||||
versions of TriliumNext/Trilium have their sync versions incremented which
|
запобігає прямій міграції.
|
||||||
prevents direct migration.
|
|
||||||
|
|
||||||
## Обговоріть це з нами
|
## Обговоріть це з нами
|
||||||
|
|
||||||
@@ -189,8 +189,8 @@ prevents direct migration.
|
|||||||
Якщо ваш дистрибутив зазначено в таблиці нижче, використовуйте пакет вашого
|
Якщо ваш дистрибутив зазначено в таблиці нижче, використовуйте пакет вашого
|
||||||
дистрибутива.
|
дистрибутива.
|
||||||
|
|
||||||
[](https://repology.org/project/triliumnext/versions)
|
упаковки](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
|
||||||
|
|
||||||
Ви також можете завантажити бінарний реліз для вашої платформи зі сторінки
|
Ви також можете завантажити бінарний реліз для вашої платформи зі сторінки
|
||||||
[останнього релізу](https://github.com/TriliumNext/Trilium/releases/latest),
|
[останнього релізу](https://github.com/TriliumNext/Trilium/releases/latest),
|
||||||
@@ -281,10 +281,10 @@ pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
|
|||||||
|
|
||||||
### Документація розробника
|
### Документація розробника
|
||||||
|
|
||||||
Please view the [documentation
|
Будь ласка, перегляньте
|
||||||
guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
|
[документацію](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
|
||||||
for details. If you have more questions, feel free to reach out via the links
|
для отримання детальної інформації. Якщо у вас виникнуть додаткові запитання,
|
||||||
described in the "Discuss with us" section above.
|
звертайтеся до нас за посиланнями, описаними в розділі «Обговоріть з нами» вище.
|
||||||
|
|
||||||
## 👏 Привітання
|
## 👏 Привітання
|
||||||
|
|
||||||
|
|||||||
322
pnpm-lock.yaml
generated
322
pnpm-lock.yaml
generated
@@ -182,9 +182,6 @@ importers:
|
|||||||
|
|
||||||
apps/client:
|
apps/client:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@algolia/autocomplete-js':
|
|
||||||
specifier: 1.19.6
|
|
||||||
version: 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)
|
|
||||||
'@excalidraw/excalidraw':
|
'@excalidraw/excalidraw':
|
||||||
specifier: 0.18.0
|
specifier: 0.18.0
|
||||||
version: 0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 0.18.0(@types/react-dom@19.1.6(@types/react@19.1.7))(@types/react@19.1.7)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
@@ -269,6 +266,9 @@ importers:
|
|||||||
'@zumer/snapdom':
|
'@zumer/snapdom':
|
||||||
specifier: 2.1.0
|
specifier: 2.1.0
|
||||||
version: 2.1.0
|
version: 2.1.0
|
||||||
|
autocomplete.js:
|
||||||
|
specifier: 0.38.1
|
||||||
|
version: 0.38.1
|
||||||
bootstrap:
|
bootstrap:
|
||||||
specifier: 5.3.8
|
specifier: 5.3.8
|
||||||
version: 5.3.8(@popperjs/core@2.11.8)
|
version: 5.3.8(@popperjs/core@2.11.8)
|
||||||
@@ -755,8 +755,8 @@ importers:
|
|||||||
specifier: 7.0.2
|
specifier: 7.0.2
|
||||||
version: 7.0.2
|
version: 7.0.2
|
||||||
https-proxy-agent:
|
https-proxy-agent:
|
||||||
specifier: 7.0.6
|
specifier: 8.0.0
|
||||||
version: 7.0.6
|
version: 8.0.0
|
||||||
i18next:
|
i18next:
|
||||||
specifier: 25.8.17
|
specifier: 25.8.17
|
||||||
version: 25.8.17(typescript@5.9.3)
|
version: 25.8.17(typescript@5.9.3)
|
||||||
@@ -1530,88 +1530,6 @@ packages:
|
|||||||
rollup:
|
rollup:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@algolia/abtesting@1.15.1':
|
|
||||||
resolution: {integrity: sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/autocomplete-core@1.19.6':
|
|
||||||
resolution: {integrity: sha512-6EoD7PeM2WBq5GY1jm0gGonDW2JVU4BaHT9tAwDcaPkc6gYIRZeY7X7aFuwdRvk9R/jwsh8sz4flDao0+Kua6g==}
|
|
||||||
|
|
||||||
'@algolia/autocomplete-js@1.19.6':
|
|
||||||
resolution: {integrity: sha512-rHYKT6P+2FZ1+7a1/JtWIuCmfioOt5eXsAcri6XTYsSutl3BIh8s2e98kbvjbhLfwEuuVDWtST1hdAY2pQdrKw==}
|
|
||||||
peerDependencies:
|
|
||||||
'@algolia/client-search': '>= 4.5.1 < 6'
|
|
||||||
algoliasearch: '>= 4.9.1 < 6'
|
|
||||||
|
|
||||||
'@algolia/autocomplete-plugin-algolia-insights@1.19.6':
|
|
||||||
resolution: {integrity: sha512-VD53DBixhEwDvOB00D03DtBVhh5crgb1N0oH3QTscfYk4TpBH+CKrwmN/XrN/VdJAdP+4K6SgwLii/3OwM9dHw==}
|
|
||||||
peerDependencies:
|
|
||||||
search-insights: '>= 1 < 3'
|
|
||||||
|
|
||||||
'@algolia/autocomplete-preset-algolia@1.19.6':
|
|
||||||
resolution: {integrity: sha512-/uQlHGK5Q2x5Nvrp3W7JMg4YNGG/ygkHtQLTltDbkpd45wnhV9jUiQA6aCnBed9cq0BXhOJZRxh1zGVZ3yRhBg==}
|
|
||||||
peerDependencies:
|
|
||||||
'@algolia/client-search': '>= 4.9.1 < 6'
|
|
||||||
algoliasearch: '>= 4.9.1 < 6'
|
|
||||||
|
|
||||||
'@algolia/autocomplete-shared@1.19.6':
|
|
||||||
resolution: {integrity: sha512-DG1n2B6XQw6DWB5veO4RuzQ/N2oGNpG+sSzGT7gUbi7WhF+jN57abcv2QhB5flXZ0NgddE1i6h7dZuQmYBEorQ==}
|
|
||||||
peerDependencies:
|
|
||||||
'@algolia/client-search': '>= 4.9.1 < 6'
|
|
||||||
algoliasearch: '>= 4.9.1 < 6'
|
|
||||||
|
|
||||||
'@algolia/client-abtesting@5.49.1':
|
|
||||||
resolution: {integrity: sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/client-analytics@5.49.1':
|
|
||||||
resolution: {integrity: sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/client-common@5.49.1':
|
|
||||||
resolution: {integrity: sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/client-insights@5.49.1':
|
|
||||||
resolution: {integrity: sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/client-personalization@5.49.1':
|
|
||||||
resolution: {integrity: sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/client-query-suggestions@5.49.1':
|
|
||||||
resolution: {integrity: sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/client-search@5.49.1':
|
|
||||||
resolution: {integrity: sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/ingestion@1.49.1':
|
|
||||||
resolution: {integrity: sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/monitoring@1.49.1':
|
|
||||||
resolution: {integrity: sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/recommend@5.49.1':
|
|
||||||
resolution: {integrity: sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/requester-browser-xhr@5.49.1':
|
|
||||||
resolution: {integrity: sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/requester-fetch@5.49.1':
|
|
||||||
resolution: {integrity: sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@algolia/requester-node-http@5.49.1':
|
|
||||||
resolution: {integrity: sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
@@ -7550,6 +7468,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
agent-base@8.0.0:
|
||||||
|
resolution: {integrity: sha512-QT8i0hCz6C/KQ+KTAbSNwCHDGdmUJl2tp2ZpNlGSWCfhUNVbYG2WLE3MdZGBAgXPV4GAvjGMxo+C1hroyxmZEg==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
agentkeepalive@4.6.0:
|
agentkeepalive@4.6.0:
|
||||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||||
engines: {node: '>= 8.0.0'}
|
engines: {node: '>= 8.0.0'}
|
||||||
@@ -7604,10 +7526,6 @@ packages:
|
|||||||
ajv@8.17.1:
|
ajv@8.17.1:
|
||||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||||
|
|
||||||
algoliasearch@5.49.1:
|
|
||||||
resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==}
|
|
||||||
engines: {node: '>= 14.0.0'}
|
|
||||||
|
|
||||||
alien-signals@0.4.14:
|
alien-signals@0.4.14:
|
||||||
resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==}
|
resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==}
|
||||||
|
|
||||||
@@ -7812,6 +7730,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
|
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
|
||||||
engines: {node: '>=0.8'}
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
|
autocomplete.js@0.38.1:
|
||||||
|
resolution: {integrity: sha512-6pSJzuRMY3pqpozt+SXThl2DmJfma8Bi3SVFbZHS0PW/N72bOUv+Db0jAh2cWOhTsA4X+GNmKvIl8wExJTnN9w==}
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -10545,9 +10466,6 @@ packages:
|
|||||||
hpack.js@2.1.6:
|
hpack.js@2.1.6:
|
||||||
resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==}
|
resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==}
|
||||||
|
|
||||||
htm@3.1.1:
|
|
||||||
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
|
|
||||||
|
|
||||||
html-encoding-sniffer@2.0.1:
|
html-encoding-sniffer@2.0.1:
|
||||||
resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==}
|
resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -10679,6 +10597,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
engines: {node: '>= 14'}
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
|
https-proxy-agent@8.0.0:
|
||||||
|
resolution: {integrity: sha512-YYeW+iCnAS3xhvj2dvVoWgsbca3RfQy/IlaNHHOtDmU0jMqPI9euIq3Y9BJETdxk16h9NHHCKqp/KB9nIMStCQ==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
human-signals@2.1.0:
|
human-signals@2.1.0:
|
||||||
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
|
||||||
engines: {node: '>=10.17.0'}
|
engines: {node: '>=10.17.0'}
|
||||||
@@ -10760,6 +10682,9 @@ packages:
|
|||||||
immediate@3.0.6:
|
immediate@3.0.6:
|
||||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
|
immediate@3.3.0:
|
||||||
|
resolution: {integrity: sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==}
|
||||||
|
|
||||||
immutable@4.3.7:
|
immutable@4.3.7:
|
||||||
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
|
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
|
||||||
|
|
||||||
@@ -14489,9 +14414,6 @@ packages:
|
|||||||
scule@1.3.0:
|
scule@1.3.0:
|
||||||
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
|
||||||
|
|
||||||
search-insights@2.17.3:
|
|
||||||
resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==}
|
|
||||||
|
|
||||||
secure-compare@3.0.1:
|
secure-compare@3.0.1:
|
||||||
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
|
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
|
||||||
|
|
||||||
@@ -16575,130 +16497,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
rollup: 4.52.0
|
rollup: 4.52.0
|
||||||
|
|
||||||
'@algolia/abtesting@1.15.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/autocomplete-core@1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/autocomplete-plugin-algolia-insights': 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)
|
|
||||||
'@algolia/autocomplete-shared': 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@algolia/client-search'
|
|
||||||
- algoliasearch
|
|
||||||
- search-insights
|
|
||||||
|
|
||||||
'@algolia/autocomplete-js@1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/autocomplete-core': 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)
|
|
||||||
'@algolia/autocomplete-preset-algolia': 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
|
||||||
'@algolia/autocomplete-shared': 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
|
||||||
'@algolia/client-search': 5.49.1
|
|
||||||
algoliasearch: 5.49.1
|
|
||||||
htm: 3.1.1
|
|
||||||
preact: 10.29.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- search-insights
|
|
||||||
|
|
||||||
'@algolia/autocomplete-plugin-algolia-insights@1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/autocomplete-shared': 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
|
||||||
search-insights: 2.17.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@algolia/client-search'
|
|
||||||
- algoliasearch
|
|
||||||
|
|
||||||
'@algolia/autocomplete-preset-algolia@1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/autocomplete-shared': 1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)
|
|
||||||
'@algolia/client-search': 5.49.1
|
|
||||||
algoliasearch: 5.49.1
|
|
||||||
|
|
||||||
'@algolia/autocomplete-shared@1.19.6(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-search': 5.49.1
|
|
||||||
algoliasearch: 5.49.1
|
|
||||||
|
|
||||||
'@algolia/client-abtesting@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/client-analytics@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/client-common@5.49.1': {}
|
|
||||||
|
|
||||||
'@algolia/client-insights@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/client-personalization@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/client-query-suggestions@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/client-search@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/ingestion@1.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/monitoring@1.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/recommend@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/requester-browser-xhr@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/requester-fetch@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
|
|
||||||
'@algolia/requester-node-http@5.49.1':
|
|
||||||
dependencies:
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
|
|
||||||
'@ampproject/remapping@2.3.0':
|
'@ampproject/remapping@2.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/gen-mapping': 0.3.13
|
'@jridgewell/gen-mapping': 0.3.13
|
||||||
@@ -17353,8 +17151,6 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-core': 47.4.0
|
'@ckeditor/ckeditor5-core': 47.4.0
|
||||||
'@ckeditor/ckeditor5-upload': 47.4.0
|
'@ckeditor/ckeditor5-upload': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-ai@47.4.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
|
'@ckeditor/ckeditor5-ai@47.4.0(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17495,16 +17291,12 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
'@ckeditor/ckeditor5-widget': 47.4.0
|
'@ckeditor/ckeditor5-widget': 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-cloud-services@47.4.0':
|
'@ckeditor/ckeditor5-cloud-services@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ckeditor/ckeditor5-core': 47.4.0
|
'@ckeditor/ckeditor5-core': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-code-block@47.4.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
|
'@ckeditor/ckeditor5-code-block@47.4.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17516,6 +17308,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-ui': 47.4.0
|
'@ckeditor/ckeditor5-ui': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-collaboration-core@47.4.0':
|
'@ckeditor/ckeditor5-collaboration-core@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17695,8 +17489,6 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-editor-classic@47.4.0':
|
'@ckeditor/ckeditor5-editor-classic@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17706,8 +17498,6 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-editor-decoupled@47.4.0':
|
'@ckeditor/ckeditor5-editor-decoupled@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17717,8 +17507,6 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-editor-inline@47.4.0':
|
'@ckeditor/ckeditor5-editor-inline@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17752,6 +17540,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-table': 47.4.0
|
'@ckeditor/ckeditor5-table': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-emoji@47.4.0':
|
'@ckeditor/ckeditor5-emoji@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17808,6 +17598,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-ui': 47.4.0
|
'@ckeditor/ckeditor5-ui': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-export-word@47.4.0':
|
'@ckeditor/ckeditor5-export-word@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17832,8 +17624,6 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-font@47.4.0':
|
'@ckeditor/ckeditor5-font@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17908,8 +17698,6 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
'@ckeditor/ckeditor5-widget': 47.4.0
|
'@ckeditor/ckeditor5-widget': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-html-embed@47.4.0':
|
'@ckeditor/ckeditor5-html-embed@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -17969,6 +17757,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-ui': 47.4.0
|
'@ckeditor/ckeditor5-ui': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-indent@47.4.0':
|
'@ckeditor/ckeditor5-indent@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18092,6 +17882,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-merge-fields@47.4.0':
|
'@ckeditor/ckeditor5-merge-fields@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18104,6 +17896,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-widget': 47.4.0
|
'@ckeditor/ckeditor5-widget': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-minimap@47.4.0':
|
'@ckeditor/ckeditor5-minimap@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18112,6 +17906,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-ui': 47.4.0
|
'@ckeditor/ckeditor5-ui': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-operations-compressor@47.4.0':
|
'@ckeditor/ckeditor5-operations-compressor@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18166,6 +17962,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
'@ckeditor/ckeditor5-widget': 47.4.0
|
'@ckeditor/ckeditor5-widget': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-pagination@47.4.0':
|
'@ckeditor/ckeditor5-pagination@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18273,6 +18071,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-ui': 47.4.0
|
'@ckeditor/ckeditor5-ui': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-slash-command@47.4.0':
|
'@ckeditor/ckeditor5-slash-command@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18285,6 +18085,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-ui': 47.4.0
|
'@ckeditor/ckeditor5-ui': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-source-editing-enhanced@47.4.0':
|
'@ckeditor/ckeditor5-source-editing-enhanced@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18332,6 +18134,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-table@47.4.0':
|
'@ckeditor/ckeditor5-table@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18344,6 +18148,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-widget': 47.4.0
|
'@ckeditor/ckeditor5-widget': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-template@47.4.0':
|
'@ckeditor/ckeditor5-template@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18454,6 +18260,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-engine': 47.4.0
|
'@ckeditor/ckeditor5-engine': 47.4.0
|
||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@ckeditor/ckeditor5-widget@47.4.0':
|
'@ckeditor/ckeditor5-widget@47.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18473,6 +18281,8 @@ snapshots:
|
|||||||
'@ckeditor/ckeditor5-utils': 47.4.0
|
'@ckeditor/ckeditor5-utils': 47.4.0
|
||||||
ckeditor5: 47.4.0
|
ckeditor5: 47.4.0
|
||||||
es-toolkit: 1.39.5
|
es-toolkit: 1.39.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@codemirror/autocomplete@6.18.6':
|
'@codemirror/autocomplete@6.18.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -25276,6 +25086,8 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
|
agent-base@8.0.0: {}
|
||||||
|
|
||||||
agentkeepalive@4.6.0:
|
agentkeepalive@4.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
humanize-ms: 1.2.1
|
humanize-ms: 1.2.1
|
||||||
@@ -25338,23 +25150,6 @@ snapshots:
|
|||||||
json-schema-traverse: 1.0.0
|
json-schema-traverse: 1.0.0
|
||||||
require-from-string: 2.0.2
|
require-from-string: 2.0.2
|
||||||
|
|
||||||
algoliasearch@5.49.1:
|
|
||||||
dependencies:
|
|
||||||
'@algolia/abtesting': 1.15.1
|
|
||||||
'@algolia/client-abtesting': 5.49.1
|
|
||||||
'@algolia/client-analytics': 5.49.1
|
|
||||||
'@algolia/client-common': 5.49.1
|
|
||||||
'@algolia/client-insights': 5.49.1
|
|
||||||
'@algolia/client-personalization': 5.49.1
|
|
||||||
'@algolia/client-query-suggestions': 5.49.1
|
|
||||||
'@algolia/client-search': 5.49.1
|
|
||||||
'@algolia/ingestion': 1.49.1
|
|
||||||
'@algolia/monitoring': 1.49.1
|
|
||||||
'@algolia/recommend': 5.49.1
|
|
||||||
'@algolia/requester-browser-xhr': 5.49.1
|
|
||||||
'@algolia/requester-fetch': 5.49.1
|
|
||||||
'@algolia/requester-node-http': 5.49.1
|
|
||||||
|
|
||||||
alien-signals@0.4.14: {}
|
alien-signals@0.4.14: {}
|
||||||
|
|
||||||
amator@1.1.0:
|
amator@1.1.0:
|
||||||
@@ -25589,6 +25384,10 @@ snapshots:
|
|||||||
|
|
||||||
author-regex@1.0.0: {}
|
author-regex@1.0.0: {}
|
||||||
|
|
||||||
|
autocomplete.js@0.38.1:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.3.0
|
||||||
|
|
||||||
available-typed-arrays@1.0.7:
|
available-typed-arrays@1.0.7:
|
||||||
dependencies:
|
dependencies:
|
||||||
possible-typed-array-names: 1.1.0
|
possible-typed-array-names: 1.1.0
|
||||||
@@ -29188,8 +28987,6 @@ snapshots:
|
|||||||
readable-stream: 2.3.8
|
readable-stream: 2.3.8
|
||||||
wbuf: 1.7.3
|
wbuf: 1.7.3
|
||||||
|
|
||||||
htm@3.1.1: {}
|
|
||||||
|
|
||||||
html-encoding-sniffer@2.0.1:
|
html-encoding-sniffer@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
whatwg-encoding: 1.0.5
|
whatwg-encoding: 1.0.5
|
||||||
@@ -29383,7 +29180,14 @@ snapshots:
|
|||||||
|
|
||||||
https-proxy-agent@7.0.6:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.3
|
agent-base: 7.1.4
|
||||||
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
https-proxy-agent@8.0.0:
|
||||||
|
dependencies:
|
||||||
|
agent-base: 8.0.0
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@8.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -29458,6 +29262,8 @@ snapshots:
|
|||||||
|
|
||||||
immediate@3.0.6: {}
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
|
immediate@3.3.0: {}
|
||||||
|
|
||||||
immutable@4.3.7: {}
|
immutable@4.3.7: {}
|
||||||
|
|
||||||
immutable@5.1.4:
|
immutable@5.1.4:
|
||||||
@@ -33719,8 +33525,6 @@ snapshots:
|
|||||||
|
|
||||||
scule@1.3.0: {}
|
scule@1.3.0: {}
|
||||||
|
|
||||||
search-insights@2.17.3: {}
|
|
||||||
|
|
||||||
secure-compare@3.0.1: {}
|
secure-compare@3.0.1: {}
|
||||||
|
|
||||||
selderee@0.11.0:
|
selderee@0.11.0:
|
||||||
|
|||||||
Reference in New Issue
Block a user