Compare commits

..

43 Commits

Author SHA1 Message Date
JYC333
39daca0c9e chore: fix pnpm lock file 2026-03-11 16:53:58 +00:00
Jin
a77323929c fix: address white space issue 2026-03-11 16:51:04 +00:00
Jin
ff203b9188 fix: test file error 2026-03-11 16:51:04 +00:00
Jin
e2892e2eaa refactor: remove plan file 2026-03-11 16:51:04 +00:00
Jin
6c38236da0 fix: add link dialog enter act correctly 2026-03-11 16:51:04 +00:00
Jin
71158ddf14 refactor: address gemini review 2026-03-11 16:51:04 +00:00
Jin
8b5fea8d6f test: add test for NoteAutocomplete 2026-03-11 16:51:04 +00:00
Jin
54104911df test: add test for autocomplete_core 2026-03-11 16:51:04 +00:00
Jin
c21b76932a test: add test for attribute_autocomplete 2026-03-11 16:51:04 +00:00
Jin
170a876c10 refactor: cleanup unused parts 2026-03-11 16:51:04 +00:00
Jin
ad1d4897ee refactor: fix related test 2026-03-11 16:51:04 +00:00
Jin
1340c9ec22 refactor: remove old autocomplete completely 2026-03-11 16:51:01 +00:00
Jin
cf9150f47e refactor: fix attribute detail can't save with ctrl+enter directly 2026-03-11 16:48:57 +00:00
Jin
97317c2952 refactor: remove old autocomplete declare 2026-03-11 16:48:57 +00:00
Jin
5598e05085 refactor: update related css 2026-03-11 16:48:57 +00:00
Jin
aeb458999a refactor: fix enter can't execute action in dialog 2026-03-11 16:48:57 +00:00
Jin
d504946de0 refactor: avoid xss attack 2026-03-11 16:48:57 +00:00
Jin
df60e34072 refactor: fix attribute panel mouse hover behavior 2026-03-11 16:48:57 +00:00
JYC333
6c5de5396d refactor: migrate cleanup function 2026-03-11 16:48:57 +00:00
JYC333
ce26734f28 refactor: extract common logic 2026-03-11 16:48:57 +00:00
JYC333
5223e2ac86 refactor: fix missing function def 2026-03-11 16:48:57 +00:00
JYC333
fb3826552a refactor: migrate react part 2026-03-11 16:48:57 +00:00
JYC333
119bbc502d refactor: address gemini review 2026-03-11 16:48:57 +00:00
JYC333
067786f94f refactor: minor cleanup 2026-03-11 16:48:57 +00:00
JYC333
d8ef842a56 refactor: fix full search 2026-03-11 16:48:57 +00:00
JYC333
a60815ad6e refactor: minor fix 2026-03-11 16:48:57 +00:00
JYC333
c7796e5afa refactor: fix attribute detail autocomplete doesn't catch default value 2026-03-11 16:48:57 +00:00
JYC333
2bacf49051 refactor: restore behaviors 2026-03-11 16:48:57 +00:00
JYC333
8373dc65fb refactor: fix behaviour difference 2026-03-11 16:48:57 +00:00
JYC333
c50a14b1c3 refactor: add back UI for note_autocomplete 2026-03-11 16:48:57 +00:00
JYC333
2cd4c17c18 refactor: migrate note_autocomplete core function 2026-03-11 16:48:57 +00:00
JYC333
ae8c727e38 refactor: address gemini code review 2026-03-11 16:48:57 +00:00
JYC333
b1a3a942c5 refactor: migrate label autocomplete 2026-03-11 16:48:57 +00:00
JYC333
5088259fb1 refactor: limit ctrl+enter action only at when creating reation on relation map 2026-03-11 16:48:57 +00:00
JYC333
8d42922172 refactor: fix cleanup to avoid DOM leaks 2026-03-11 16:48:56 +00:00
JYC333
d14dd07e55 refactor: use ctrl+enter to confirm in relation creation at relation map page 2026-03-11 16:48:56 +00:00
JYC333
6a65c20fbb fix: relation definition is not included when create relation in relation map 2026-03-11 16:48:56 +00:00
JYC333
79d2fd5601 refactor: clean up old autocomplete implementation 2026-03-11 16:48:56 +00:00
JYC333
5facd5ddb4 refactor: migrate relation map 2026-03-11 16:48:56 +00:00
JYC333
1e688419b4 fix: dropdown menu not follow the input when attribute detail dialog height changed 2026-03-11 16:48:56 +00:00
JYC333
622f7ef263 refactor: use headless autocomplete, migrate attribute deatil 2026-03-11 16:48:56 +00:00
JYC333
1cdd04e193 refactor: add new autocomplete registry 2026-03-11 16:48:56 +00:00
JYC333
d85b67aefc refactor: add plan and package 2026-03-11 16:48:56 +00:00
46 changed files with 2860 additions and 1285 deletions

View File

@@ -16,6 +16,7 @@
"circular-deps": "dpdm -T src/**/*.ts --tree=false --warning=false --skip-dynamic-imports=circular"
},
"dependencies": {
"@algolia/autocomplete-js": "1.19.6",
"@excalidraw/excalidraw": "0.18.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
@@ -44,7 +45,6 @@
"@univerjs/preset-sheets-sort": "0.16.1",
"@univerjs/presets": "0.16.1",
"@zumer/snapdom": "2.1.0",
"autocomplete.js": "0.38.1",
"bootstrap": "5.3.8",
"boxicons": "2.1.4",
"clsx": "2.1.1",
@@ -69,7 +69,7 @@
"normalize.css": "8.0.1",
"panzoom": "9.4.3",
"preact": "10.29.0",
"react-i18next": "16.5.8",
"react-i18next": "16.5.6",
"react-window": "2.2.7",
"reveal.js": "5.2.1",
"rrule": "2.8.1",
@@ -93,4 +93,4 @@
"script-loader": "0.7.2",
"vite-plugin-static-copy": "3.2.0"
}
}
}

View File

@@ -1,5 +1,6 @@
import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons";
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
import bundleService from "../services/bundle.js";
import dateNoteService from "../services/date_notes.js";
import froca from "../services/froca.js";
@@ -197,7 +198,7 @@ export default class Entrypoints extends Component {
hideAllPopups() {
if (utils.isDesktop()) {
$(".aa-input").autocomplete("close");
closeAllHeadlessAutocompletes();
}
}

View File

@@ -1,15 +1,16 @@
import Component from "./component.js";
import SpacedUpdate from "../services/spaced_update.js";
import server from "../services/server.js";
import options from "../services/options.js";
import froca from "../services/froca.js";
import treeService from "../services/tree.js";
import NoteContext from "./note_context.js";
import appContext from "./app_context.js";
import Mutex from "../utils/mutex.js";
import linkService from "../services/link.js";
import type { EventData } from "./app_context.js";
import type FNote from "../entities/fnote.js";
import { closeAllHeadlessAutocompletes } from "../services/autocomplete_core.js";
import froca from "../services/froca.js";
import linkService from "../services/link.js";
import options from "../services/options.js";
import server from "../services/server.js";
import SpacedUpdate from "../services/spaced_update.js";
import treeService from "../services/tree.js";
import Mutex from "../utils/mutex.js";
import type { EventData } from "./app_context.js";
import appContext from "./app_context.js";
import Component from "./component.js";
import NoteContext from "./note_context.js";
interface TabState {
contexts: NoteContext[];
@@ -429,10 +430,7 @@ export default class TabManager extends Component {
}
// close dangling autocompletes after closing the tab
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
closeAllHeadlessAutocompletes();
// close dangling tooltips
$("body > div.tooltip").remove();

View File

@@ -1,5 +1,3 @@
import "autocomplete.js/index_jquery.js";
import type ElectronRemote from "@electron/remote";
import type Electron from "electron";

View File

@@ -16,17 +16,6 @@ async function initJQuery() {
const $ = (await import("jquery")).default;
window.$ = $;
window.jQuery = $;
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
}
async function setupGlob() {

View File

@@ -1,5 +1,3 @@
import "autocomplete.js/index_jquery.js";
import appContext from "./components/app_context.js";
import glob from "./services/glob.js";
import noteAutocompleteService from "./services/note_autocomplete.js";

View File

@@ -8,17 +8,6 @@ async function loadBootstrap() {
}
}
// Polyfill removed jQuery methods for autocomplete.js compatibility
($ as any).isArray = Array.isArray;
($ as any).isFunction = function(obj: any) { return typeof obj === 'function'; };
($ as any).isPlainObject = function(obj: any) {
if (obj == null || typeof obj !== 'object') { return false; }
const proto = Object.getPrototypeOf(obj);
if (proto === null) { return true; }
const Ctor = Object.prototype.hasOwnProperty.call(proto, 'constructor') && proto.constructor;
return typeof Ctor === 'function' && Ctor === Object;
};
(window as any).$ = $;
(window as any).jQuery = $;
await loadBootstrap();

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { shouldAutocompleteHandleEnterKey } from "./attribute_autocomplete.js";
describe("attribute autocomplete enter handling", () => {
it("delegates plain Enter when the panel is open and an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(true);
});
it("does not delegate plain Enter when there is no active suggestion", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: true, hasActiveItem: false }
)).toBe(false);
});
it("does not delegate plain Enter when the panel is closed", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: false },
{ isPanelOpen: false, hasActiveItem: false }
)).toBe(false);
});
it("does not delegate Ctrl+Enter even when an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: true, metaKey: false },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(false);
});
it("does not delegate Cmd+Enter even when an item is active", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "Enter", ctrlKey: false, metaKey: true },
{ isPanelOpen: true, hasActiveItem: true }
)).toBe(false);
});
it("ignores non-Enter keys", () => {
expect(shouldAutocompleteHandleEnterKey(
{ key: "ArrowDown", ctrlKey: false, metaKey: false },
{ isPanelOpen: false, hasActiveItem: false }
)).toBe(true);
});
});

View File

@@ -1,114 +1,450 @@
import type { AutocompleteApi as CoreAutocompleteApi, BaseItem } from "@algolia/autocomplete-core";
import { createAutocomplete } from "@algolia/autocomplete-core";
import type { AttributeType } from "../entities/fattribute.js";
import { bindAutocompleteInput, createHeadlessPanelController, registerHeadlessAutocompleteCloser, withHeadlessSourceDefaults } from "./autocomplete_core.js";
import server from "./server.js";
interface InitOptions {
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface NameItem extends BaseItem {
name: string;
}
export function shouldAutocompleteHandleEnterKey(
event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey">,
{ isPanelOpen, hasActiveItem }: { isPanelOpen: boolean; hasActiveItem: boolean }
) {
if (event.key !== "Enter") {
return true;
}
if (event.ctrlKey || event.metaKey) {
return false;
}
return isPanelOpen && hasActiveItem;
}
interface InitAttributeNameOptions {
/** The <input> element where the user types */
$el: JQuery<HTMLElement>;
attributeType?: AttributeType | (() => AttributeType);
open: boolean;
nameCallback?: () => string;
/** Called when the user selects a value or the panel closes */
onValueChange?: (value: string) => void;
}
/**
* @param $el - element on which to init autocomplete
* @param attributeType - "relation" or "label" or callback providing one of those values as a type of autocompleted attributes
* @param open - should the autocomplete be opened after init?
*/
function initAttributeNameAutocomplete({ $el, attributeType, open }: InitOptions) {
if (!$el.hasClass("aa-input")) {
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "name",
// disabling cache is important here because otherwise cache can stay intact when switching between attribute type which will lead to autocomplete displaying attribute names for incorrect attribute type
cache: false,
source: async (term, cb) => {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
// ---------------------------------------------------------------------------
// Instance tracking
// ---------------------------------------------------------------------------
const names = await server.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(term)}`);
const result = names.map((name) => ({ name }));
interface ManagedInstance {
autocomplete: CoreAutocompleteApi<NameItem>;
panelEl: HTMLElement;
cleanup: () => void;
}
cb(result);
}
}
]
);
const instanceMap = new WeakMap<HTMLElement, ManagedInstance>();
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
function renderItems(
panelEl: HTMLElement,
items: NameItem[],
activeItemId: number | null,
onSelect: (item: NameItem) => void,
onActivate: (index: number) => void,
onDeactivate: () => void
): void {
panelEl.innerHTML = "";
if (items.length === 0) {
panelEl.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "aa-core-list";
items.forEach((item, index) => {
const li = document.createElement("li");
li.className = "aa-core-item";
if (index === activeItemId) {
li.classList.add("aa-core-item--active");
}
li.textContent = item.name;
li.addEventListener("mousemove", () => {
if (activeItemId === index) {
return;
}
onActivate(index);
});
}
li.addEventListener("mouseleave", (event) => {
const relatedTarget = event.relatedTarget;
if (relatedTarget instanceof HTMLElement && li.contains(relatedTarget)) {
return;
}
if (open) {
$el.autocomplete("open");
}
onDeactivate();
});
li.addEventListener("mousedown", (e) => {
e.preventDefault(); // prevent input blur
e.stopPropagation();
onSelect(item);
});
list.appendChild(li);
});
panelEl.appendChild(list);
}
async function initLabelValueAutocomplete({ $el, open, nameCallback }: InitOptions) {
if ($el.hasClass("aa-input")) {
// we reinit every time because autocomplete seems to have a bug where it retains state from last
// open even though the value was reset
$el.autocomplete("destroy");
}
// ---------------------------------------------------------------------------
// Attribute name autocomplete — new (autocomplete-core, headless)
// ---------------------------------------------------------------------------
let attributeName = "";
if (nameCallback) {
attributeName = nameCallback();
}
function initAttributeNameAutocomplete({ $el, attributeType, open, onValueChange }: InitAttributeNameOptions) {
const inputEl = $el[0] as HTMLInputElement;
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
autocomplete.setQuery(inputEl.value || "");
};
if (attributeName.trim() === "") {
// Already initialized — just open if requested
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
syncQueryFromInputValue(inst.autocomplete);
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
return;
}
const attributeValues = (await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`)).map((attribute) => ({ value: attribute }));
const panelController = createHeadlessPanelController({ inputEl });
const { panelEl } = panelController;
if (attributeValues.length === 0) {
return;
}
let isPanelOpen = false;
let hasActiveItem = false;
$el.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
openOnFocus: false, // handled manually
minLength: 0,
tabAutocomplete: false
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: 0,
shouldPanelOpen() {
return true;
},
[
{
displayKey: "value",
cache: false,
source: async function (term, cb) {
term = term.toLowerCase();
const filtered = attributeValues.filter((attr) => attr.value.toLowerCase().includes(term));
getSources({ query }) {
return [
withHeadlessSourceDefaults({
sourceId: "attribute-names",
getItems() {
const type = typeof attributeType === "function" ? attributeType() : attributeType;
return server
.get<string[]>(`attribute-names/?type=${type}&query=${encodeURIComponent(query)}`)
.then((names) => names.map((name) => ({ name })));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
},
}),
];
},
cb(filtered);
}
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
// Render items
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(
panelEl,
items,
activeId,
(item) => {
inputEl.value = item.name;
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
},
(index) => {
autocomplete.setActiveItemId(index);
},
() => {
autocomplete.setActiveItemId(null);
}
);
panelController.startPositioning();
} else {
panelController.hide();
}
]
);
$el.on("autocomplete:opened", () => {
if ($el.attr("readonly")) {
$el.autocomplete("close");
if (!state.isOpen) {
panelController.hide();
}
},
});
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
autocomplete.setIsOpen(false);
panelController.hide();
});
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
inputEl,
autocomplete,
onInput(e, handlers) {
handlers.onChange(e as any);
},
onFocus(e, handlers) {
syncQueryFromInputValue(autocomplete);
handlers.onFocus(e as any);
},
onBlur() {
// Delay to allow mousedown on panel items
setTimeout(() => {
autocomplete.setIsOpen(false);
panelController.hide();
onValueChange?.(inputEl.value);
}, 50);
},
onKeyDown(e, handlers) {
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
return;
}
if (e.key === "Enter") {
// Prevent the enter key from propagating to parent dialogs
// (which might interpret it as "submit" or "save and close")
e.stopPropagation();
}
handlers.onKeyDown(e as any);
}
});
const cleanup = () => {
unregisterGlobalCloser();
cleanupInputBindings();
panelController.destroy();
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
$el.autocomplete("open");
syncQueryFromInputValue(autocomplete);
autocomplete.setIsOpen(true);
autocomplete.refresh();
panelController.startPositioning();
}
}
// ---------------------------------------------------------------------------
// Label value autocomplete (headless autocomplete-core)
// ---------------------------------------------------------------------------
interface LabelValueInitOptions {
$el: JQuery<HTMLElement>;
open: boolean;
nameCallback?: () => string;
onValueChange?: (value: string) => void;
}
function initLabelValueAutocomplete({ $el, open, nameCallback, onValueChange }: LabelValueInitOptions) {
const inputEl = $el[0] as HTMLInputElement;
const syncQueryFromInputValue = (autocomplete: CoreAutocompleteApi<NameItem>) => {
autocomplete.setQuery(inputEl.value || "");
};
if (instanceMap.has(inputEl)) {
if (open) {
const inst = instanceMap.get(inputEl)!;
syncQueryFromInputValue(inst.autocomplete);
inst.autocomplete.setIsOpen(true);
inst.autocomplete.refresh();
}
return;
}
const panelController = createHeadlessPanelController({ inputEl });
const { panelEl } = panelController;
let isPanelOpen = false;
let hasActiveItem = false;
let isSelecting = false;
let cachedAttributeName = "";
let cachedAttributeValues: NameItem[] = [];
const handleSelect = (item: NameItem) => {
isSelecting = true;
inputEl.value = item.name;
inputEl.dispatchEvent(new Event("input", { bubbles: true }));
autocomplete.setQuery(item.name);
autocomplete.setIsOpen(false);
onValueChange?.(item.name);
isSelecting = false;
setTimeout(() => {
// Preserve the legacy contract: several consumers still commit the
// selected value from their existing Enter key handlers instead of
// listening to the autocomplete selection event directly.
inputEl.dispatchEvent(new KeyboardEvent("keydown", {
key: "Enter",
code: "Enter",
keyCode: 13,
which: 13,
bubbles: true,
cancelable: true
}));
}, 0);
};
const autocomplete = createAutocomplete<NameItem>({
openOnFocus: true,
defaultActiveItemId: null,
shouldPanelOpen() {
return true;
},
getSources({ query }) {
return [
withHeadlessSourceDefaults({
sourceId: "attribute-values",
async getItems() {
const attributeName = nameCallback ? nameCallback() : "";
if (!attributeName.trim()) {
return [];
}
if (attributeName !== cachedAttributeName || cachedAttributeValues.length === 0) {
cachedAttributeName = attributeName;
const values = await server.get<string[]>(`attribute-values/${encodeURIComponent(attributeName)}`);
cachedAttributeValues = values.map((name) => ({ name }));
}
const q = query.toLowerCase();
return cachedAttributeValues.filter((attr) => attr.name.toLowerCase().includes(q));
},
getItemInputValue({ item }) {
return item.name;
},
onSelect({ item }) {
handleSelect(item);
},
}),
];
},
onStateChange({ state }) {
isPanelOpen = state.isOpen;
hasActiveItem = state.activeItemId !== null;
const collections = state.collections;
const items = collections.length > 0 ? (collections[0].items as NameItem[]) : [];
const activeId = state.activeItemId ?? null;
if (state.isOpen && items.length > 0) {
renderItems(
panelEl,
items,
activeId,
handleSelect,
(index) => {
autocomplete.setActiveItemId(index);
},
() => {
autocomplete.setActiveItemId(null);
}
);
panelController.startPositioning();
} else {
panelController.hide();
}
if (!state.isOpen) {
panelController.hide();
}
},
});
const unregisterGlobalCloser = registerHeadlessAutocompleteCloser(() => {
autocomplete.setIsOpen(false);
panelController.hide();
});
const cleanupInputBindings = bindAutocompleteInput<NameItem>({
inputEl,
autocomplete,
onInput(e, handlers) {
if (!isSelecting) {
handlers.onChange(e as any);
}
},
onFocus(e, handlers) {
const attributeName = nameCallback ? nameCallback() : "";
if (attributeName !== cachedAttributeName) {
cachedAttributeName = "";
cachedAttributeValues = [];
}
syncQueryFromInputValue(autocomplete);
handlers.onFocus(e as any);
},
onBlur() {
setTimeout(() => {
autocomplete.setIsOpen(false);
panelController.hide();
onValueChange?.(inputEl.value);
}, 50);
},
onKeyDown(e, handlers) {
if (!shouldAutocompleteHandleEnterKey(e, { isPanelOpen, hasActiveItem })) {
return;
}
if (e.key === "Enter") {
e.stopPropagation();
}
handlers.onKeyDown(e as any);
}
});
const cleanup = () => {
unregisterGlobalCloser();
cleanupInputBindings();
panelController.destroy();
};
instanceMap.set(inputEl, { autocomplete, panelEl, cleanup });
if (open) {
syncQueryFromInputValue(autocomplete);
autocomplete.setIsOpen(true);
autocomplete.refresh();
panelController.startPositioning();
}
}
export function destroyAutocomplete($el: JQuery<HTMLElement> | HTMLElement) {
const inputEl = $el instanceof HTMLElement ? $el : $el[0] as HTMLInputElement;
const instance = instanceMap.get(inputEl);
if (instance) {
instance.cleanup();
instanceMap.delete(inputEl);
}
}
export default {
initAttributeNameAutocomplete,
initLabelValueAutocomplete
destroyAutocomplete,
initLabelValueAutocomplete,
};

View File

@@ -0,0 +1,93 @@
import $ from "jquery";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
showSpy,
hideSpy,
updateDisplayedShortcutsSpy,
saveFocusedElementSpy,
focusSavedElementSpy
} = vi.hoisted(() => ({
showSpy: vi.fn(),
hideSpy: vi.fn(),
updateDisplayedShortcutsSpy: vi.fn(),
saveFocusedElementSpy: vi.fn(),
focusSavedElementSpy: vi.fn()
}));
vi.mock("bootstrap", () => ({
Modal: {
getOrCreateInstance: vi.fn(() => ({
show: showSpy,
hide: hideSpy
}))
}
}));
vi.mock("./keyboard_actions.js", () => ({
default: {
updateDisplayedShortcuts: updateDisplayedShortcutsSpy
}
}));
vi.mock("./focus.js", () => ({
saveFocusedElement: saveFocusedElementSpy,
focusSavedElement: focusSavedElementSpy
}));
import { closeAllHeadlessAutocompletes, registerHeadlessAutocompleteCloser } from "./autocomplete_core.js";
import { openDialog } from "./dialog.js";
describe("headless autocomplete closing", () => {
const unregisterClosers: Array<() => void> = [];
beforeEach(() => {
vi.clearAllMocks();
(window as any).glob = {
...(window as any).glob,
activeDialog: null
};
});
afterEach(() => {
while (unregisterClosers.length > 0) {
unregisterClosers.pop()?.();
}
});
it("closes every registered closer and skips unregistered ones", () => {
const closer1 = vi.fn();
const closer2 = vi.fn();
const closer3 = vi.fn();
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer1));
const unregister2 = registerHeadlessAutocompleteCloser(closer2);
unregisterClosers.push(unregister2);
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer3));
unregister2();
closeAllHeadlessAutocompletes();
expect(closer1).toHaveBeenCalledTimes(1);
expect(closer2).not.toHaveBeenCalled();
expect(closer3).toHaveBeenCalledTimes(1);
});
it("closes registered autocompletes when a dialog finishes hiding", async () => {
const closer = vi.fn();
unregisterClosers.push(registerHeadlessAutocompleteCloser(closer));
const dialogEl = document.createElement("div");
const $dialog = $(dialogEl);
await openDialog($dialog, false);
$dialog.trigger("hidden.bs.modal");
expect(showSpy).toHaveBeenCalledTimes(1);
expect(updateDisplayedShortcutsSpy).toHaveBeenCalledWith($dialog);
expect(saveFocusedElementSpy).toHaveBeenCalledTimes(1);
expect(closer).toHaveBeenCalledTimes(1);
expect(focusSavedElementSpy).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,195 @@
import type { AutocompleteApi, AutocompleteSource, BaseItem } from "@algolia/autocomplete-core";
export const HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR = ".aa-core-panel";
type HeadlessSourceDefaults = Required<Pick<AutocompleteSource<any>, "getItemUrl" | "onActive" | "onResolve">>;
const headlessAutocompleteClosers = new Set<() => void>();
export function withHeadlessSourceDefaults<TSource extends AutocompleteSource<any>>(
source: TSource
): TSource & HeadlessSourceDefaults {
return {
getItemUrl() {
return undefined;
},
onActive() {
// Headless consumers handle highlight side effects themselves.
},
onResolve() {
// Headless consumers resolve and render items manually.
},
...source
} as TSource & HeadlessSourceDefaults;
}
export function registerHeadlessAutocompleteCloser(close: () => void) {
headlessAutocompleteClosers.add(close);
return () => {
headlessAutocompleteClosers.delete(close);
};
}
export function closeAllHeadlessAutocompletes() {
for (const close of Array.from(headlessAutocompleteClosers)) {
close();
}
}
interface HeadlessPanelControllerOptions {
inputEl: HTMLElement;
container?: HTMLElement | null;
className?: string;
containedClassName?: string;
}
export function createHeadlessPanelController({
inputEl,
container,
className = "aa-core-panel",
containedClassName = "aa-core-panel--contained"
}: HeadlessPanelControllerOptions) {
const panelEl = document.createElement("div");
panelEl.className = className;
const isContained = Boolean(container);
if (isContained) {
panelEl.classList.add(containedClassName);
container!.appendChild(panelEl);
} else {
document.body.appendChild(panelEl);
}
panelEl.style.display = "none";
let rafId: number | null = null;
const positionPanel = () => {
if (isContained) {
panelEl.style.position = "static";
panelEl.style.top = "";
panelEl.style.left = "";
panelEl.style.width = "100%";
panelEl.style.display = "block";
return;
}
const rect = inputEl.getBoundingClientRect();
panelEl.style.position = "fixed";
panelEl.style.top = `${rect.bottom}px`;
panelEl.style.left = `${rect.left}px`;
panelEl.style.width = `${rect.width}px`;
panelEl.style.display = "block";
};
const stopPositioning = () => {
if (rafId !== null) {
cancelAnimationFrame(rafId);
rafId = null;
}
};
const startPositioning = () => {
if (isContained) {
positionPanel();
return;
}
if (rafId !== null) {
return;
}
const update = () => {
positionPanel();
rafId = requestAnimationFrame(update);
};
update();
};
const hide = () => {
panelEl.style.display = "none";
stopPositioning();
};
const destroy = () => {
hide();
panelEl.remove();
};
return {
panelEl,
hide,
destroy,
startPositioning,
stopPositioning
};
}
type InputHandlers<TItem extends BaseItem> = ReturnType<AutocompleteApi<TItem>["getInputProps"]>;
interface InputBinding<TEvent extends Event = Event> {
type: string;
listener: (event: TEvent) => void;
}
interface BindAutocompleteInputOptions<TItem extends BaseItem> {
inputEl: HTMLInputElement;
autocomplete: AutocompleteApi<TItem>;
onInput?: (event: Event, handlers: InputHandlers<TItem>) => void;
onFocus?: (event: Event, handlers: InputHandlers<TItem>) => void;
onBlur?: (event: Event, handlers: InputHandlers<TItem>) => void;
onKeyDown?: (event: KeyboardEvent, handlers: InputHandlers<TItem>) => void;
extraBindings?: InputBinding[];
}
export function bindAutocompleteInput<TItem extends BaseItem>({
inputEl,
autocomplete,
onInput,
onFocus,
onBlur,
onKeyDown,
extraBindings = []
}: BindAutocompleteInputOptions<TItem>) {
const handlers = autocomplete.getInputProps({ inputElement: inputEl });
const bindings: InputBinding[] = [
{
type: "input",
listener: (event: Event) => {
onInput?.(event, handlers);
}
},
{
type: "focus",
listener: (event: Event) => {
onFocus?.(event, handlers);
}
},
{
type: "blur",
listener: (event: Event) => {
onBlur?.(event, handlers);
}
},
{
type: "keydown",
listener: (event: Event) => {
onKeyDown?.(event as KeyboardEvent, handlers);
}
},
...extraBindings
];
bindings.forEach(({ type, listener }) => {
inputEl.addEventListener(type, listener as EventListener);
});
return () => {
bindings.forEach(({ type, listener }) => {
inputEl.removeEventListener(type, listener as EventListener);
});
};
}

View File

@@ -1,9 +1,11 @@
import { Modal } from "bootstrap";
import appContext from "../components/app_context.js";
import type { ConfirmDialogOptions, ConfirmDialogResult, ConfirmWithMessageOptions, MessageType } from "../widgets/dialogs/confirm.js";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
import { InfoExtraProps } from "../widgets/dialogs/info.jsx";
import type { PromptDialogOptions } from "../widgets/dialogs/prompt.js";
import { closeAllHeadlessAutocompletes } from "./autocomplete_core.js";
import { focusSavedElement, saveFocusedElement } from "./focus.js";
export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog = true, config?: Partial<Modal.Options>) {
if (closeActDialog) {
@@ -15,10 +17,7 @@ export async function openDialog($dialog: JQuery<HTMLElement>, closeActDialog =
Modal.getOrCreateInstance($dialog[0], config).show();
$dialog.on("hidden.bs.modal", () => {
const $autocompleteEl = $(".aa-input");
if ("autocomplete" in $autocompleteEl) {
$autocompleteEl.autocomplete("close");
}
closeAllHeadlessAutocompletes();
if (!glob.activeDialog || glob.activeDialog === $dialog) {
focusSavedElement();

View File

@@ -1,9 +1,10 @@
import server from "./server.js";
import appContext from "../components/app_context.js";
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
import type Component from "../components/component.js";
import type { ActionKeyboardShortcut } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import type Component from "../components/component.js";
import server from "./server.js";
import shortcutService, { ShortcutBinding } from "./shortcuts.js";
const keyboardActionRepo: Record<string, ActionKeyboardShortcut> = {};
const keyboardActionsLoaded = server.get<ActionKeyboardShortcut[]>("keyboard-actions").then((actions) => {
@@ -51,7 +52,10 @@ async function setupActionsForElement(scope: string, $el: JQuery<HTMLElement>, c
getActionsForScope("window").then((actions) => {
for (const action of actions) {
for (const shortcut of action.effectiveShortcuts ?? []) {
shortcutService.bindGlobalShortcut(shortcut, () => appContext.triggerCommand(action.actionName, { ntxId: appContext.tabManager.activeNtxId }));
shortcutService.bindGlobalShortcut(shortcut, () => {
const ntxId = appContext.tabManager?.activeNtxId ?? null;
appContext.triggerCommand(action.actionName, { ntxId });
});
}
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -892,33 +892,6 @@ table.promoted-attributes-in-tooltip th {
opacity: 1;
}
.algolia-autocomplete {
width: calc(100% - 30px);
z-index: 2000 !important;
}
.algolia-autocomplete-container .aa-dropdown-menu {
position: inherit !important;
overflow: auto;
}
.algolia-autocomplete .aa-input,
.algolia-autocomplete .aa-hint {
width: 100%;
}
.algolia-autocomplete .aa-dropdown-menu {
width: 100%;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-top: none;
z-index: 2000 !important;
max-height: 500px;
overflow: auto;
padding: 0;
margin: 0;
}
.aa-dropdown-menu .aa-suggestion {
cursor: pointer;
padding: 6px 16px;
@@ -960,6 +933,153 @@ table.promoted-attributes-in-tooltip th {
background-color: var(--active-item-background-color);
}
/* ===== @algolia/autocomplete-core (headless, custom panel) ===== */
.aa-core-panel {
z-index: 10000;
background-color: var(--main-background-color);
border: 1px solid var(--main-border-color);
border-top: none;
max-height: 500px;
overflow: auto;
padding: 0;
margin: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.aa-core-panel.aa-dropdown-menu {
width: 100%;
}
.aa-core-panel--contained {
position: static !important;
border: 0;
background: transparent;
box-shadow: none;
}
.aa-core-list {
list-style: none;
padding: 0;
margin: 0;
}
.aa-core-item {
cursor: pointer;
padding: 7px 16px;
margin: 0;
white-space: normal;
}
.aa-core-item--active {
color: var(--active-item-text-color);
background-color: var(--active-item-background-color);
}
.aa-core-item .note-suggestion {
display: flex;
align-items: flex-start;
gap: 8px;
width: 100%;
}
.aa-core-item .icon,
.aa-core-item .command-icon {
flex-shrink: 0;
line-height: 1.4;
margin-top: 1px;
}
.aa-core-item .text {
min-width: 0;
flex: 1;
}
.aa-core-item .aa-core-primary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.aa-core-item .search-result-title {
display: block;
min-width: 0;
line-height: 1.35;
word-break: break-word;
font-size: 1.02em;
}
.aa-core-item .search-result-attributes {
display: block;
margin-top: 1px;
font-size: 0.8em;
color: var(--muted-text-color);
opacity: 0.65;
line-height: 1.2;
word-break: break-word;
}
.aa-core-item .search-result-attributes {
padding-inline-start: 14px;
}
.aa-core-item .aa-core-shortcut,
.aa-core-item kbd.command-shortcut {
flex-shrink: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--muted-text-color);
font-family: inherit !important;
font-size: 0.8em;
opacity: 0.85;
}
.aa-core-item .command-suggestion {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
font-size: 0.9em;
}
.aa-core-item .command-content {
flex-grow: 1;
min-width: 0;
}
.aa-core-item .command-name {
font-weight: bold;
line-height: 1.35;
}
.aa-core-item .command-description {
font-size: 0.8em;
line-height: 1.3;
opacity: 0.75;
}
.aa-core-item .search-result-title b,
.aa-core-item .search-result-path b,
.aa-core-item .search-result-attributes b,
.aa-core-item .command-name b,
.aa-core-item .command-description b {
color: var(--admonition-warning-accent-color);
text-decoration: underline;
}
.aa-core-item .aa-core-separator {
padding: 0 2px;
}
.jump-to-note-results .aa-core-panel--contained {
max-height: calc(80vh - 200px);
overflow-y: auto;
overflow-x: hidden;
text-overflow: ellipsis;
}
.help-button {
float: inline-end;
background: none;

View File

@@ -128,8 +128,8 @@
margin-inline: auto;
}
/* The search results list */
.note-detail-empty span.aa-dropdown-menu {
/* The headless autocomplete panel rendered into the empty-note results container */
.note-detail-empty .aa-core-panel--contained {
margin-top: 1em;
border: unset;
}

View File

@@ -803,13 +803,12 @@
"web-view": "عرض الويب",
"mind-map": "خريطة ذهنية",
"geo-map": "خريطة جغرافية",
"task-list": "قائمة المهام",
"spreadsheet": "جدول البيانات"
"task-list": "قائمة المهام"
},
"shared_switch": {
"shared": "مشترك",
"toggle-on-title": "مشاركة الملاحظة",
"toggle-off-title": "إلغاء مشاركة الملاحظة"
"toggle-off-title": "الغاء مشاركة الملاحظة"
},
"template_switch": {
"template": "قالب"
@@ -1287,10 +1286,8 @@
"search-for": "بحث ل \"{{term}}\""
},
"protect_note": {
"toggle-off": "إزالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة",
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
"toggle-off": "ازالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة"
},
"open-help-page": "فتح صفحة المساعدة",
"empty": {

View File

@@ -1036,7 +1036,7 @@
"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."
},
"media": {
"video": {
"play": "Play (Space)",
"pause": "Pause (Space)",
"back-10s": "Back 10s (Left arrow key)",
@@ -1051,7 +1051,7 @@
"exit-picture-in-picture": "Exit picture-in-picture",
"fullscreen": "Fullscreen (F)",
"exit-fullscreen": "Exit fullscreen",
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
"unsupported-format": "Video preview is not available for this file format.",
"zoom-to-fit": "Zoom to fill",
"zoom-reset": "Reset zoom to fill"
},

View File

@@ -1780,8 +1780,7 @@
"ai-chat": "Czat AI",
"task-list": "Lista zadań",
"new-feature": "Nowość",
"collections": "Kolekcje",
"spreadsheet": "Arkusz"
"collections": "Kolekcje"
},
"protect_note": {
"toggle-on": "Chroń notatkę",

View File

@@ -6,7 +6,6 @@ import type { PrintReport } from "./print";
import type { lint } from "./services/eslint";
import type { Froca } from "./services/froca-interface";
import { Library } from "./services/library_loader";
import { Suggestion } from "./services/note_autocomplete";
import server from "./services/server";
import utils from "./services/utils";
@@ -83,34 +82,7 @@ declare global {
"note-load-progress": CustomEvent<{ progress: number }>;
}
interface AutoCompleteConfig {
appendTo?: HTMLElement | null;
hint?: boolean;
openOnFocus?: boolean;
minLength?: number;
tabAutocomplete?: boolean;
autoselect?: boolean;
dropdownMenuContainer?: HTMLElement;
debug?: boolean;
}
type AutoCompleteCallback = (values: AutoCompleteArg[]) => void;
interface AutoCompleteArg {
name?: string;
value?: string;
notePathTitle?: string;
displayKey?: "name" | "value" | "notePathTitle";
cache?: boolean;
source?: (term: string, cb: AutoCompleteCallback) => void,
templates?: {
suggestion: (suggestion: Suggestion) => string | undefined
}
}
interface JQuery {
autocomplete: (action?: "close" | "open" | "destroy" | "val" | AutoCompleteConfig, args?: AutoCompleteArg[] | string) => JQuery<HTMLElement>;
getSelectedNotePath(): string | undefined;
getSelectedNoteId(): string | null;
setSelectedNotePath(notePath: string | null | undefined);

View File

@@ -8,6 +8,7 @@ import { Dispatch, StateUpdater, useCallback, useEffect, useRef, useState } from
import NoteContext from "../components/note_context";
import FAttribute from "../entities/fattribute";
import FNote from "../entities/fnote";
import attributeAutocompleteService from "../services/attribute_autocomplete";
import { Attribute } from "../services/attribute_parser";
import attributes from "../services/attributes";
import { t } from "../services/i18n";
@@ -36,8 +37,7 @@ interface CellProps {
setCellToFocus(cell: Cell): void;
}
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent | JQuery.TriggeredEvent<HTMLInputElement, undefined, HTMLInputElement, HTMLInputElement>;
type OnChangeListener = (e: OnChangeEventData) => void | Promise<void>;
type OnChangeEventData = TargetedEvent<HTMLInputElement, Event> | InputEvent;
export default function PromotedAttributes() {
const { note, componentId, noteContext } = useNoteContext();
@@ -200,11 +200,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
}, [ cell, componentId, note, setCells ]);
const extraInputProps: InputHTMLAttributes = {};
useTextLabelAutocomplete(inputId, valueAttr, definition, (e) => {
if (e.currentTarget instanceof HTMLInputElement) {
setDraft(e.currentTarget.value);
}
});
useTextLabelAutocomplete(inputId, valueAttr, definition, setDraft);
// React to model changes.
useEffect(() => {
@@ -258,7 +254,7 @@ function LabelInput(props: CellProps & { inputId: string }) {
className="open-external-link-button"
icon="bx bx-window-open"
title={t("promoted_attributes.open_external_link")}
onClick={(e) => {
onClick={() => {
const inputEl = document.getElementById(inputId) as HTMLInputElement | null;
const url = inputEl?.value;
if (url) {
@@ -413,55 +409,31 @@ function InputButton({ icon, className, title, onClick }: {
);
}
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onChangeListener: OnChangeListener) {
const [ attributeValues, setAttributeValues ] = useState<{ value: string }[] | null>(null);
// Obtain data.
function useTextLabelAutocomplete(inputId: string, valueAttr: Attribute, definition: DefinitionObject, onValueChange: (value: string) => void) {
useEffect(() => {
if (definition.labelType !== "text") {
return;
}
server.get<string[]>(`attribute-values/${encodeURIComponent(valueAttr.name)}`).then((_attributesValues) => {
setAttributeValues(_attributesValues.map((attribute) => ({ value: attribute })));
});
}, [ definition.labelType, valueAttr.name ]);
// Initialize autocomplete.
useEffect(() => {
if (attributeValues?.length === 0) return;
const el = document.getElementById(inputId) as HTMLInputElement | null;
if (!el) return;
if (!el) {
return;
}
const $input = $(el);
$input.autocomplete(
{
appendTo: document.querySelector("body"),
hint: false,
autoselect: false,
openOnFocus: true,
minLength: 0,
tabAutocomplete: false
},
[
{
displayKey: "value",
source (term, cb) {
term = term.toLowerCase();
attributeAutocompleteService.initLabelValueAutocomplete({
$el: $input,
open: false,
nameCallback: () => valueAttr.name,
onValueChange: (value) => {
onValueChange(value);
}
});
const filtered = (attributeValues ?? []).filter((attr) => attr.value.toLowerCase().includes(term));
cb(filtered);
}
}
]
);
$input.off("autocomplete:selected");
$input.on("autocomplete:selected", onChangeListener);
return () => $input.autocomplete("destroy");
}, [ inputId, attributeValues, onChangeListener ]);
return () => {
attributeAutocompleteService.destroyAutocomplete($input);
};
}, [ definition.labelType, inputId, onValueChange, valueAttr.name ]);
}
async function updateAttribute(note: FNote, cell: Cell, componentId: string, value: string | undefined, setCells: Dispatch<StateUpdater<Cell[] | undefined>>) {

View File

@@ -1,18 +1,19 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import linkService from "../../services/link.js";
import appContext from "../../components/app_context.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR } from "../../services/autocomplete_core.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import froca from "../../services/froca.js";
import { t } from "../../services/i18n.js";
import linkService from "../../services/link.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import server from "../../services/server.js";
import shortcutService from "../../services/shortcuts.js";
import SpacedUpdate from "../../services/spaced_update.js";
import utils from "../../services/utils.js";
import shortcutService from "../../services/shortcuts.js";
import appContext from "../../components/app_context.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<div class="attr-detail tn-tool-dialog">
@@ -372,13 +373,13 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
}
});
this.$inputName.on("change", () => this.userEditedAttribute());
this.$inputName.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputName.on("focus", () => {
attributeAutocompleteService.initAttributeNameAutocomplete({
$el: this.$inputName,
attributeType: () => (["relation", "relation-definition"].includes(this.attrType || "") ? "relation" : "label"),
open: true
open: true,
onValueChange: () => this.userEditedAttribute(),
});
});
@@ -391,12 +392,12 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
}
});
this.$inputValue.on("change", () => this.userEditedAttribute());
this.$inputValue.on("autocomplete:closed", () => this.userEditedAttribute());
this.$inputValue.on("focus", () => {
attributeAutocompleteService.initLabelValueAutocomplete({
$el: this.$inputValue,
open: true,
nameCallback: () => String(this.$inputName.val())
nameCallback: () => String(this.$inputName.val()),
onValueChange: () => this.userEditedAttribute(),
});
});
@@ -477,7 +478,9 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
this.$relatedNotesMoreNotes = this.$relatedNotesContainer.find(".related-notes-more-notes");
$(window).on("mousedown", (e) => {
if (!$(e.target).closest(this.$widget[0]).length && !$(e.target).closest(".algolia-autocomplete").length && !$(e.target).closest("#context-menu-container").length) {
if (!$(e.target).closest(this.$widget[0]).length
&& !$(e.target).closest(HEADLESS_AUTOCOMPLETE_PANEL_SELECTOR).length
&& !$(e.target).closest("#context-menu-container").length) {
this.hide();
}
});

View File

@@ -0,0 +1,160 @@
import $ from "jquery";
import type { ComponentChildren } from "preact";
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
triliumEventHandlers,
latestModalPropsRef,
latestNoteAutocompletePropsRef,
addLinkSpy,
logErrorSpy,
showRecentNotesSpy,
setTextSpy
} = vi.hoisted(() => ({
triliumEventHandlers: new Map<string, (payload: any) => void>(),
latestModalPropsRef: { current: null as any },
latestNoteAutocompletePropsRef: { current: null as any },
addLinkSpy: vi.fn(() => Promise.resolve()),
logErrorSpy: vi.fn(),
showRecentNotesSpy: vi.fn(),
setTextSpy: vi.fn()
}));
vi.mock("../../services/i18n", () => ({
t: (key: string) => key
}));
vi.mock("../../services/tree", () => ({
default: {
getNoteIdFromUrl: (notePath: string) => notePath.split("/").at(-1),
getNoteTitle: vi.fn(async () => "Target note")
}
}));
vi.mock("../../services/ws", () => ({
logError: logErrorSpy
}));
vi.mock("../../services/note_autocomplete", () => ({
__esModule: true,
default: {
showRecentNotes: showRecentNotesSpy,
setText: setTextSpy
}
}));
vi.mock("../react/react_utils", () => ({
refToJQuerySelector: (ref: { current: HTMLInputElement | null }) => ref.current ? $(ref.current) : $()
}));
vi.mock("../react/hooks", () => ({
useTriliumEvent: (name: string, handler: (payload: any) => void) => {
triliumEventHandlers.set(name, handler);
}
}));
vi.mock("../react/Modal", () => ({
default: (props: any) => {
latestModalPropsRef.current = props;
if (!props.show) {
return null;
}
return (
<form onSubmit={(e) => {
e.preventDefault();
props.onSubmit?.();
}}>
{props.children}
{props.footer}
</form>
);
}
}));
vi.mock("../react/FormGroup", () => ({
default: ({ children }: { children: ComponentChildren }) => <div>{children}</div>
}));
vi.mock("../react/Button", () => ({
default: ({ text }: { text: string }) => <button type="submit">{text}</button>
}));
vi.mock("../react/FormRadioGroup", () => ({
default: () => null
}));
vi.mock("../react/NoteAutocomplete", () => ({
default: (props: any) => {
latestNoteAutocompletePropsRef.current = props;
return <input ref={props.inputRef} />;
}
}));
import AddLinkDialog from "./add_link";
describe("AddLinkDialog", () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.clearAllMocks();
latestModalPropsRef.current = null;
latestNoteAutocompletePropsRef.current = null;
triliumEventHandlers.clear();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
act(() => {
render(null, container);
});
container.remove();
});
it("submits the selected note when Enter picks an autocomplete suggestion", async () => {
act(() => {
render(<AddLinkDialog />, container);
});
const showDialog = triliumEventHandlers.get("showAddLinkDialog");
expect(showDialog).toBeTypeOf("function");
await act(async () => {
showDialog?.({
text: "",
hasSelection: false,
addLink: addLinkSpy
});
});
const suggestion = {
notePath: "root/target-note",
noteTitle: "Target note"
};
act(() => {
latestNoteAutocompletePropsRef.current.onKeyDownCapture({
key: "Enter",
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
isComposing: false
});
latestNoteAutocompletePropsRef.current.onChange(suggestion);
});
expect(latestModalPropsRef.current.show).toBe(false);
expect(logErrorSpy).not.toHaveBeenCalled();
await act(async () => {
latestModalPropsRef.current.onHidden();
});
expect(addLinkSpy).toHaveBeenCalledWith("root/target-note", null);
});
});

View File

@@ -1,15 +1,17 @@
import type { JSX } from "preact";
import { useEffect,useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import Modal from "../react/Modal";
import Button from "../react/Button";
import FormRadioGroup from "../react/FormRadioGroup";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { useRef, useState, useEffect } from "preact/hooks";
import tree from "../../services/tree";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import tree from "../../services/tree";
import { logError } from "../../services/ws";
import Button from "../react/Button";
import FormGroup from "../react/FormGroup.js";
import { refToJQuerySelector } from "../react/react_utils";
import FormRadioGroup from "../react/FormRadioGroup";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { refToJQuerySelector } from "../react/react_utils";
type LinkType = "reference-link" | "external-link" | "hyper-link";
@@ -26,6 +28,8 @@ export default function AddLinkDialog() {
const [ suggestion, setSuggestion ] = useState<Suggestion | null>(null);
const [ shown, setShown ] = useState(false);
const hasSubmittedRef = useRef(false);
const suggestionRef = useRef<Suggestion | null>(null);
const submitOnSelectionRef = useRef(false);
useTriliumEvent("showAddLinkDialog", opts => {
setOpts(opts);
@@ -85,15 +89,44 @@ export default function AddLinkDialog() {
.trigger("select");
}
function onSubmit() {
hasSubmittedRef.current = true;
function submitSelectedLink(selectedSuggestion: Suggestion | null) {
submitOnSelectionRef.current = false;
hasSubmittedRef.current = Boolean(selectedSuggestion);
if (suggestion) {
// Insertion logic in onHidden because it needs focus.
setShown(false);
} else {
if (!selectedSuggestion) {
logError("No link to add.");
return;
}
// Insertion logic in onHidden because it needs focus.
setShown(false);
}
function onSuggestionChange(nextSuggestion: Suggestion | null) {
suggestionRef.current = nextSuggestion;
setSuggestion(nextSuggestion);
if (submitOnSelectionRef.current && nextSuggestion) {
submitSelectedLink(nextSuggestion);
}
}
function onAutocompleteKeyDownCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
if (e.key !== "Enter" || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey || e.isComposing) {
return;
}
submitOnSelectionRef.current = true;
}
function onAutocompleteKeyUpCapture(e: JSX.TargetedKeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
submitOnSelectionRef.current = false;
}
}
function onSubmit() {
submitSelectedLink(suggestionRef.current);
}
const autocompleteRef = useRef<HTMLInputElement>(null);
@@ -109,19 +142,22 @@ export default function AddLinkDialog() {
onSubmit={onSubmit}
onShown={onShown}
onHidden={() => {
submitOnSelectionRef.current = false;
// Insert the link.
if (hasSubmittedRef.current && suggestion && opts) {
if (hasSubmittedRef.current && suggestionRef.current && opts) {
hasSubmittedRef.current = false;
if (suggestion.notePath) {
if (suggestionRef.current.notePath) {
// Handle note link
opts.addLink(suggestion.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestion.externalLink) {
opts.addLink(suggestionRef.current.notePath, linkType === "reference-link" ? null : linkTitle);
} else if (suggestionRef.current.externalLink) {
// Handle external link
opts.addLink(suggestion.externalLink, linkTitle, true);
opts.addLink(suggestionRef.current.externalLink, linkTitle, true);
}
}
suggestionRef.current = null;
setSuggestion(null);
setShown(false);
}}
@@ -130,7 +166,9 @@ export default function AddLinkDialog() {
<FormGroup label={t("add_link.note")} name="note">
<NoteAutocomplete
inputRef={autocompleteRef}
onChange={setSuggestion}
onChange={onSuggestionChange}
onKeyDownCapture={onAutocompleteKeyDownCapture}
onKeyUpCapture={onAutocompleteKeyUpCapture}
opts={{
allowExternalLinks: true,
allowCreatingNotes: true

View File

@@ -1,14 +1,15 @@
import Modal from "../react/Modal";
import Button from "../react/Button";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { t } from "../../services/i18n";
import { useRef, useState } from "preact/hooks";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import appContext from "../../components/app_context";
import commandRegistry from "../../services/command_registry";
import { refToJQuerySelector } from "../react/react_utils";
import { useTriliumEvent } from "../react/hooks";
import { t } from "../../services/i18n";
import note_autocomplete, { Suggestion } from "../../services/note_autocomplete";
import shortcutService from "../../services/shortcuts";
import Button from "../react/Button";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import NoteAutocomplete from "../react/NoteAutocomplete";
import { refToJQuerySelector } from "../react/react_utils";
const KEEP_LAST_SEARCH_FOR_X_SECONDS = 120;
@@ -23,14 +24,14 @@ export default function JumpToNoteDialogComponent() {
const [ initialText, setInitialText ] = useState(isCommandMode ? "> " : "");
const actualText = useRef<string>(initialText);
const [ shown, setShown ] = useState(false);
async function openDialog(commandMode: boolean) {
async function openDialog(commandMode: boolean) {
let newMode: Mode;
let initialText = "";
if (commandMode) {
newMode = "commands";
initialText = ">";
initialText = ">";
} else if (Date.now() - lastOpenedTs <= KEEP_LAST_SEARCH_FOR_X_SECONDS * 1000 && actualText.current) {
// if you open the Jump To dialog soon after using it previously, it can often mean that you
// actually want to search for the same thing (e.g., you opened the wrong note at first try)
@@ -58,7 +59,7 @@ export default function JumpToNoteDialogComponent() {
if (!suggestion) {
return;
}
setShown(false);
if (suggestion.notePath) {
appContext.tabManager.getActiveContext()?.setNote(suggestion.notePath);
@@ -83,7 +84,7 @@ export default function JumpToNoteDialogComponent() {
$autoComplete
.trigger("focus")
.trigger("select");
// Add keyboard shortcut for full search
shortcutService.bindElShortcut($autoComplete, "ctrl+return", () => {
if (!isCommandMode) {
@@ -91,7 +92,7 @@ export default function JumpToNoteDialogComponent() {
}
});
}
async function showInFullSearch() {
try {
setShown(false);
@@ -126,18 +127,18 @@ export default function JumpToNoteDialogComponent() {
setIsCommandMode(text.startsWith(">"));
}}
onChange={onItemSelected}
/>}
/>}
onShown={onShown}
onHidden={() => setShown(false)}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
footer={!isCommandMode && <Button
className="show-in-full-text-button"
text={t("jump_to_note.search_button")}
keyboardShortcut="Ctrl+Enter"
onClick={showInFullSearch}
/>}
show={shown}
>
<div className="algolia-autocomplete-container jump-to-note-results" ref={containerRef}></div>
<div className="jump-to-note-results" ref={containerRef} />
</Modal>
);
}

View File

@@ -1,11 +1,12 @@
import { useRef, useState } from "preact/hooks";
import { t } from "../../services/i18n";
import Button from "../react/Button";
import Modal from "../react/Modal";
import FormTextBox from "../react/FormTextBox";
import FormGroup from "../react/FormGroup";
import { refToJQuerySelector } from "../react/react_utils";
import FormTextBox from "../react/FormTextBox";
import { useTriliumEvent } from "../react/hooks";
import Modal from "../react/Modal";
import { refToJQuerySelector } from "../react/react_utils";
// JQuery here is maintained for compatibility with existing code.
interface ShownCallbackData {
@@ -40,7 +41,7 @@ export default function PromptDialog() {
opts.current = newOpts;
setValue(newOpts.defaultValue ?? "");
setShown(true);
})
});
return (
<Modal
@@ -60,7 +61,7 @@ export default function PromptDialog() {
answerRef.current?.select();
}}
onSubmit={() => {
submitValue.current = value;
submitValue.current = answerRef.current?.value || value;
setShown(false);
}}
onHidden={() => {

View File

@@ -83,7 +83,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
return true;
}
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/"))) {
return true;
}
@@ -108,7 +108,7 @@ export default class NoteWrapperWidget extends FlexContainer<BasicWidget> {
return true;
}
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
if (note.type === "file" && MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime)) {
return true;
}

View File

@@ -8,7 +8,6 @@
color: var(--muted-text-color);
height: 100%;
text-align: center;
white-space: pre-line;
.tn-icon {
font-size: 4em;

View File

@@ -0,0 +1,135 @@
import $ from "jquery";
import { render } from "preact";
import { act } from "preact/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
initNoteAutocompleteSpy,
setTextSpy,
clearTextSpy
} = vi.hoisted(() => ({
initNoteAutocompleteSpy: vi.fn(($el) => $el),
setTextSpy: vi.fn(),
clearTextSpy: vi.fn()
}));
vi.mock("../../services/i18n", () => ({
t: (key: string) => key
}));
vi.mock("../../services/note_autocomplete", () => ({
__esModule: true,
default: {
initNoteAutocomplete: initNoteAutocompleteSpy,
setText: setTextSpy,
clearText: clearTextSpy
}
}));
import NoteAutocomplete from "./NoteAutocomplete";
describe("NoteAutocomplete", () => {
let container: HTMLDivElement;
let setNoteSpy: ReturnType<typeof vi.fn>;
let getSelectedNoteIdSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement("div");
document.body.appendChild(container);
setNoteSpy = vi.fn(() => Promise.resolve());
getSelectedNoteIdSpy = vi.fn(() => "selected-note-id");
($.fn as any).setNote = setNoteSpy;
($.fn as any).getSelectedNoteId = getSelectedNoteIdSpy;
});
afterEach(() => {
act(() => {
render(null, container);
});
container.remove();
});
it("syncs text props through the headless helper functions", () => {
act(() => {
render(<NoteAutocomplete text="hello" />, container);
});
const input = container.querySelector("input") as HTMLInputElement;
expect(initNoteAutocompleteSpy).toHaveBeenCalledTimes(1);
expect(initNoteAutocompleteSpy.mock.calls[0][0][0]).toBe(input);
expect(setTextSpy).toHaveBeenCalledTimes(1);
expect(setTextSpy.mock.calls[0][0][0]).toBe(input);
expect(setTextSpy).toHaveBeenCalledWith(expect.anything(), "hello");
act(() => {
render(<NoteAutocomplete text="" />, container);
});
expect(clearTextSpy).toHaveBeenCalled();
});
it("syncs noteId props through the jQuery setNote extension", () => {
act(() => {
render(<NoteAutocomplete noteId="note-123" />, container);
});
expect(setNoteSpy).toHaveBeenCalledWith("note-123");
expect(clearTextSpy).not.toHaveBeenCalled();
});
it("forwards autocomplete selection and clear events to consumers", () => {
const onChange = vi.fn();
const noteIdChanged = vi.fn();
act(() => {
render(<NoteAutocomplete onChange={onChange} noteIdChanged={noteIdChanged} />, container);
});
const input = container.querySelector("input") as HTMLInputElement;
const $input = $(input);
const suggestion = { notePath: "root/child-note", noteTitle: "Child note" };
$input.trigger("autocomplete:noteselected", [suggestion]);
expect(onChange).toHaveBeenCalledWith(suggestion);
expect(noteIdChanged).toHaveBeenCalledWith("child-note");
input.value = "";
$input.trigger("change");
expect(onChange).toHaveBeenCalledWith(null);
});
it("forwards onTextChange, onKeyDown and onBlur events", () => {
const onTextChange = vi.fn();
const onKeyDown = vi.fn();
const onBlur = vi.fn();
act(() => {
render(
<NoteAutocomplete
onTextChange={onTextChange}
onKeyDown={onKeyDown}
onBlur={onBlur}
/>,
container
);
});
const input = container.querySelector("input") as HTMLInputElement;
const $input = $(input);
input.value = "typed text";
$input.trigger("input");
$input.trigger($.Event("keydown", { originalEvent: new KeyboardEvent("keydown", { key: "Enter" }) }));
$input.trigger("blur");
expect(onTextChange).toHaveBeenCalledWith("typed text");
expect(onKeyDown).toHaveBeenCalledWith(expect.any(KeyboardEvent));
expect(onBlur).toHaveBeenCalledWith("selected-note-id");
});
});

View File

@@ -1,8 +1,9 @@
import { t } from "../../services/i18n";
import { useEffect } from "preact/hooks";
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
import type { RefObject } from "preact";
import type { JSX,RefObject } from "preact";
import type { CSSProperties } from "preact/compat";
import { useEffect } from "preact/hooks";
import { t } from "../../services/i18n";
import note_autocomplete, { Options, type Suggestion } from "../../services/note_autocomplete";
import { useSyncedRef } from "./hooks";
interface NoteAutocompleteProps {
@@ -16,85 +17,111 @@ interface NoteAutocompleteProps {
onChange?: (suggestion: Suggestion | null) => void;
onTextChange?: (text: string) => void;
onKeyDown?: (e: KeyboardEvent) => void;
onKeyDownCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
onKeyUpCapture?: JSX.KeyboardEventHandler<HTMLInputElement>;
onBlur?: (newValue: string) => void;
noteIdChanged?: (noteId: string) => void;
noteId?: string;
}
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onBlur }: NoteAutocompleteProps) {
export default function NoteAutocomplete({ id, inputRef: externalInputRef, text, placeholder, onChange, onTextChange, container, containerStyle, opts, noteId, noteIdChanged, onKeyDown, onKeyDownCapture, onKeyUpCapture, onBlur }: NoteAutocompleteProps) {
const ref = useSyncedRef<HTMLInputElement>(externalInputRef);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
// clear any event listener added in previous invocation of this function
$autoComplete
.off("autocomplete:noteselected")
.off("autocomplete:commandselected")
note_autocomplete.initNoteAutocomplete($autoComplete, {
...opts,
container: container?.current
});
if (onTextChange) {
$autoComplete.on("input", () => onTextChange($autoComplete[0].value));
}
if (onKeyDown) {
$autoComplete.on("keydown", (e) => e.originalEvent && onKeyDown(e.originalEvent));
}
if (onBlur) {
$autoComplete.on("blur", () => onBlur($autoComplete.getSelectedNoteId() ?? ""));
}
}, [opts, container?.current]);
// On change event handlers.
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
const inputListener = () => onTextChange?.($autoComplete[0].value);
const keyDownListener = (e) => e.originalEvent && onKeyDown?.(e.originalEvent);
const blurListener = () => onBlur?.($autoComplete.getSelectedNoteId() ?? "");
if (onChange || noteIdChanged) {
const autoCompleteListener = (_e, suggestion) => {
onChange?.(suggestion);
if (noteIdChanged) {
const noteId = suggestion?.notePath?.split("/")?.at(-1);
noteIdChanged(noteId);
}
};
const changeListener = (e) => {
if (!ref.current?.value) {
autoCompleteListener(e, null);
}
};
$autoComplete
.on("autocomplete:noteselected", autoCompleteListener)
.on("autocomplete:externallinkselected", autoCompleteListener)
.on("autocomplete:commandselected", autoCompleteListener)
.on("change", changeListener);
return () => {
$autoComplete
.off("autocomplete:noteselected", autoCompleteListener)
.off("autocomplete:externallinkselected", autoCompleteListener)
.off("autocomplete:commandselected", autoCompleteListener)
.off("change", changeListener);
};
if (onTextChange) {
$autoComplete.on("input", inputListener);
}
}, [opts, container?.current, onChange, noteIdChanged])
if (onKeyDown) {
$autoComplete.on("keydown", keyDownListener);
}
if (onBlur) {
$autoComplete.on("blur", blurListener);
}
return () => {
if (onTextChange) {
$autoComplete.off("input", inputListener);
}
if (onKeyDown) {
$autoComplete.off("keydown", keyDownListener);
}
if (onBlur) {
$autoComplete.off("blur", blurListener);
}
};
}, [onBlur, onKeyDown, onTextChange]);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
if (!(onChange || noteIdChanged)) {
return;
}
const autoCompleteListener = (_e, suggestion) => {
onChange?.(suggestion);
if (noteIdChanged) {
const noteId = suggestion?.notePath?.split("/")?.at(-1);
noteIdChanged(noteId);
}
};
const changeListener = (e) => {
if (!ref.current?.value) {
autoCompleteListener(e, null);
}
};
$autoComplete
.on("autocomplete:noteselected", autoCompleteListener)
.on("autocomplete:externallinkselected", autoCompleteListener)
.on("autocomplete:commandselected", autoCompleteListener)
.on("change", changeListener);
return () => {
$autoComplete
.off("autocomplete:noteselected", autoCompleteListener)
.off("autocomplete:externallinkselected", autoCompleteListener)
.off("autocomplete:commandselected", autoCompleteListener)
.off("change", changeListener);
};
}, [onChange, noteIdChanged]);
useEffect(() => {
if (!ref.current) return;
const $autoComplete = $(ref.current);
if (noteId) {
$autoComplete.setNote(noteId);
} else if (text) {
note_autocomplete.setText($autoComplete, text);
} else {
$autoComplete.setSelectedNotePath("");
$autoComplete.autocomplete("val", "");
ref.current.value = "";
void $autoComplete.setNote(noteId);
return;
}
if (text !== undefined) {
if (text) {
note_autocomplete.setText($autoComplete, text);
} else {
note_autocomplete.clearText($autoComplete);
}
return;
}
note_autocomplete.clearText($autoComplete);
}, [text, noteId]);
return (
@@ -103,6 +130,8 @@ export default function NoteAutocomplete({ id, inputRef: externalInputRef, text,
id={id}
ref={ref}
className="note-autocomplete form-control"
onKeyDownCapture={onKeyDownCapture}
onKeyUpCapture={onKeyUpCapture}
placeholder={placeholder ?? t("add_link.search_note")} />
</div>
);

View File

@@ -50,9 +50,8 @@ body.desktop {
border-radius: 8px;
}
.note-detail-empty-results .aa-dropdown-menu {
border: var(--bs-border-width) solid var(--bs-border-color);
border-top: 0;
.note-detail-empty-results .aa-core-panel--contained {
border: 0;
}
.empty-tab-search label {

View File

@@ -1,9 +1,10 @@
import "./File.css";
import FNote from "../../entities/fnote";
import { t } from "../../services/i18n";
import { getUrlForDownload } from "../../services/open";
import Alert from "../react/Alert";
import { useNoteBlob } from "../react/hooks";
import AudioPreview from "./file/Audio";
import PdfPreview from "./file/Pdf";
import VideoPreview from "./file/Video";
import { TypeWidgetProps } from "./type_widget";
@@ -42,6 +43,16 @@ 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() {
return (
<Alert className="file-preview-not-available" type="info">

View File

@@ -1,112 +0,0 @@
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 ]);
}

View File

@@ -1,98 +0,0 @@
.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;
}
}

View File

@@ -1,220 +0,0 @@
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>
);
}

View File

@@ -2,7 +2,7 @@
width: 100%;
height: 100%;
position: relative;
background-color: black;
background-color: black;
.video-preview {
background-color: black;
@@ -13,23 +13,102 @@
&.controls-hidden {
cursor: pointer;
.media-preview-controls {
.video-preview-controls {
opacity: 0;
pointer-events: none;
}
}
.media-preview-controls {
--icon-button-hover-color: white;
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
opacity: 1;
transition: opacity 300ms ease;
.video-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);
backdrop-filter: blur(6px);
color: white;
--icon-button-hover-color: white;
--icon-button-hover-background: rgba(255, 255, 255, 0.2);
opacity: 1;
transition: opacity 300ms ease;
.video-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;
}
}
}
.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;
}
}
}

View File

@@ -1,14 +1,21 @@
import "./Video.css";
import { RefObject } from "preact";
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import Icon from "../../react/Icon";
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;
@@ -33,56 +40,11 @@ export default function VideoPreview({ note }: { note: FNote }) {
}, []);
const onVideoClick = useCallback((e: MouseEvent) => {
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
if ((e.target as HTMLElement).closest(".video-preview-controls")) return;
togglePlayback();
}, [togglePlayback]);
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 onKeyDown = useCallback((e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
@@ -138,7 +100,48 @@ function useKeyboardShortcuts(videoRef: MutableRef<HTMLVideoElement | null>, wra
flashControls();
break;
}
}, [ wrapperRef, videoRef, togglePlayback, flashControls ]);
}, [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) {
@@ -150,7 +153,7 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
if (videoRef.current && !videoRef.current.paused) {
hideTimerRef.current = setTimeout(() => setVisible(false), AUTO_HIDE_DELAY);
}
}, [ videoRef]);
}, []);
const onMouseMove = useCallback(() => {
setVisible(true);
@@ -171,6 +174,219 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
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> }) {
const [rotation, setRotation] = useState(0);
@@ -198,7 +414,7 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
return (
<ActionButton
icon="bx bx-rotate-right"
text={t("media.rotate")}
text={t("video.rotate")}
onClick={rotate}
/>
);
@@ -219,7 +435,7 @@ function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }
<ActionButton
className={fitted ? "active" : ""}
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
text={fitted ? t("video.zoom-reset") : t("video.zoom-to-fit")}
onClick={toggle}
/>
);
@@ -244,7 +460,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
video.removeEventListener("enterpictureinpicture", onEnter);
video.removeEventListener("leavepictureinpicture", onLeave);
};
}, [ videoRef, supported ]);
}, [supported]);
if (!supported) return null;
@@ -262,7 +478,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
return (
<ActionButton
icon={active ? "bx bx-exit" : "bx bx-window-open"}
text={active ? t("media.exit-picture-in-picture") : t("media.picture-in-picture")}
text={active ? t("video.exit-picture-in-picture") : t("video.picture-in-picture")}
onClick={toggle}
/>
);
@@ -291,7 +507,7 @@ function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> })
return (
<ActionButton
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
text={isFullscreen ? t("media.exit-fullscreen") : t("media.fullscreen")}
text={isFullscreen ? t("video.exit-fullscreen") : t("video.fullscreen")}
onClick={toggleFullscreen}
/>
);

View File

@@ -1,4 +1,5 @@
import { test, expect } from "@playwright/test";
import { expect,test } from "@playwright/test";
import App from "../support/app";
const TEXT_NOTE_TITLE = "Text notes";
@@ -32,8 +33,7 @@ test("Open the note in the correct split pane", async ({ page, context }) => {
await noteContent.focus();
// Click the search result in the second split.
await resultsSelector.locator(".aa-suggestion", { hasText: CODE_NOTE_TITLE })
.nth(1).click();
await app.getNoteAutocompleteSuggestion(resultsSelector, CODE_NOTE_TITLE).click();
await expect(split2).toContainText(CODE_NOTE_TITLE);
});
@@ -69,4 +69,4 @@ test("Can directly focus the autocomplete input within the split", async ({ page
await page.waitForTimeout(100);
await expect(autocomplete).toBeFocused();
});
});

View File

@@ -27,6 +27,7 @@ export default class App {
readonly currentNoteSplitContent: Locator;
readonly sidebar: Locator;
private isMobile: boolean = false;
private readonly noteAutocompleteSuggestionSelector = ".aa-suggestion:not(.create-note-action):not(.search-notes-action):not(.command-action):not(.external-link-action)";
constructor(page: Page, context: BrowserContext) {
this.page = page;
@@ -76,12 +77,19 @@ export default class App {
const resultsSelector = this.currentNoteSplit.locator(".note-detail-empty-results");
await expect(resultsSelector).toContainText(noteTitle);
const suggestionSelector = resultsSelector.locator(".aa-suggestion")
.nth(1); // Select the second one (best candidate), as the first one is "Create a new note"
const suggestionSelector = resultsSelector
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
.first();
await expect(suggestionSelector).toContainText(noteTitle);
await suggestionSelector.click();
}
getNoteAutocompleteSuggestion(resultsContainer: Locator, noteTitle: string) {
return resultsContainer
.locator(this.noteAutocompleteSuggestionSelector, { hasText: noteTitle })
.first();
}
async goToSettings() {
await this.page.locator(".launcher-button.bx-cog").click();
}

View File

@@ -71,6 +71,27 @@ function getAttributeNames(type: string, nameLike: string) {
[type, `%${nameLike}%`]
);
// Also include attribute definitions (e.g. 'relation:*' or 'label:*') which are saved as type='label'
if (type === "relation" || type === "label") {
const prefix = `${type}:`;
const defNames = sql.getColumn<string>(
/*sql*/`SELECT DISTINCT name
FROM attributes
WHERE isDeleted = 0
AND type = 'label'
AND name LIKE ?`,
[`${prefix}%${nameLike}%`]
);
for (const dn of defNames) {
if (dn.startsWith(prefix)) {
const stripped = dn.substring(prefix.length);
if (!names.includes(stripped)) {
names.push(stripped);
}
}
}
}
for (const attr of BUILTIN_ATTRIBUTES) {
if (attr.type === type && attr.name.toLowerCase().includes(nameLike) && !names.includes(attr.name)) {
names.push(attr.name);

View File

@@ -14,7 +14,7 @@
"preact": "10.29.0",
"preact-iso": "2.11.1",
"preact-render-to-string": "6.6.6",
"react-i18next": "16.5.8"
"react-i18next": "16.5.6"
},
"devDependencies": {
"@preact/preset-vite": "2.10.3",

View File

@@ -197,12 +197,5 @@
"description": "Trilium Notes는 간편한 접근 및 관리를 위해 유료 서비스인 PikaPods에서 호스팅할 수 있습니다. Trilium 팀과 직접 제휴되어있지는 않습니다.",
"download_pikapod": "PikaPods에서 설치하기",
"download_triliumcc": "또는 trilium.cc를 참조하세요"
},
"resources": {
"title": "리소스",
"icon_packs": "아이콘 팩",
"icon_packs_intro": "아이콘 팩을 사용하여 노트에 사용할 수 있는 아이콘 종류를 늘려보세요. 아이콘 팩에 대한 자세한 내용은 <DocumentationLink>공식 문서</DocumentationLink>를 참조하세요.",
"download": "다운로드",
"website": "웹사이트"
}
}

View File

@@ -50,9 +50,7 @@
"canvas_description": "Розташовуйте фігури, зображення та текст на нескінченному полотні, використовуючи ту саму технологію, що й excalidraw.com. Ідеально підходить для діаграм, ескізів та візуального планування.",
"mermaid_description": "Створюйте діаграми, такі як блок-схеми, діаграми класів та послідовностей, діаграми Ганта та багато іншого, використовуючи синтаксис Mermaid.",
"others_list": "та інші: <0>карта нотаток</0>, <1>карта зв'язків</1>, <2>збережені пошуки</2>, <3>візуалізація нотаток</3> та <4>веб-перегляди</4>.",
"mermaid_title": "Mermaid діаграми",
"mindmap_title": "Карта думок",
"mindmap_description": "Візуально упорядкуйте свої думки або проведіть мозковий штурм."
"mermaid_title": "Mermaid діаграми"
},
"extensibility_benefits": {
"title": "Спільне використання та розширюваність",
@@ -61,9 +59,7 @@
"share_title": "Діліться нотатками в Інтернеті",
"share_description": "Якщо у Вас є сервер, Ви можете використати його, щоб поділитися частиною своїх нотаток з іншими людьми.",
"api_title": "REST API",
"api_description": "Взаємодійте з Trilium програмно, використовуючи його вбудований REST API.",
"scripting_title": "Розширений скриптинг",
"scripting_description": "Створюйте власні інтеграції в Trilium за допомогою користувацьких віджетів або серверної логіки."
"api_description": "Взаємодійте з Trilium програмно, використовуючи його вбудований REST API."
},
"collections": {
"title": "Колекції",
@@ -112,8 +108,7 @@
"header": {
"get-started": "Почати",
"documentation": "Документація",
"support-us": "Підтримайте нас",
"resources": "Ресурси"
"support-us": "Підтримайте нас"
},
"footer": {
"copyright_and_the": " і ",
@@ -153,8 +148,7 @@
"description_arm64": "Сумісний з пристроями ARM (наприклад, з Qualcomm Snapdragon).",
"quick_start": "Щоб встановити через Winget:",
"download_exe": "Завантажити інсталятор (.exe)",
"download_zip": "Портативний (.zip)",
"download_scoop": "Scoop"
"download_zip": "Портативний (.zip)"
},
"download_helper_desktop_linux": {
"title_x64": "Linux 64-bit",
@@ -165,44 +159,23 @@
"download_deb": ".deb",
"download_rpm": ".rpm",
"download_flatpak": ".flatpak",
"download_nixpkgs": "nixpkgs",
"download_zip": "Portable (.zip)",
"download_aur": "AUR"
"download_nixpkgs": "nixpkgs"
},
"download_helper_desktop_macos": {
"title_x64": "macOS для Intel",
"title_arm64": "macOS для Apple Silicon",
"quick_start": "Для того, щоб встановити за допомогою Homebrew:",
"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_homebrew_cask": "Homebrew Cask"
},
"download_helper_server_docker": {
"download_dockerhub": "Docker Hub",
"download_ghcr": "ghcr.io",
"title": "Self-hosted using Docker",
"description": "Легке розгортання на Windows, Linux або macOS за допомогою контейнера Docker."
"download_ghcr": "ghcr.io"
},
"download_helper_server_linux": {
"download_tar_x64": "x64 (.tar.xz)",
"download_tar_arm64": "ARM (.tar.xz)",
"title": "Self-hosted on Linux",
"description": "Розгорніть Trilium Notes на власному сервері або VPS, сумісному з більшістю дистрибутивів.",
"download_nixos": "NixOS module"
"download_tar_arm64": "ARM (.tar.xz)"
},
"download_helper_server_hosted": {
"title": "Платний хостинг",
"description": "Нотатки Trilium розміщені на PikaPods, платному сервісі для легкого доступу та керування. Не пов'язаний безпосередньо з командою Trilium.",
"download_pikapod": "Налаштування на PikaPods",
"download_triliumcc": "Або див. trilium.cc"
},
"resources": {
"title": "Ресурси",
"icon_packs": "Пакети піктограм",
"icon_packs_intro": "Розширте вибір доступних піктограм для ваших нотаток за допомогою пакету піктограм. Щоб отримати докладнішу інформацію про пакети піктограм, див. <DocumentationLink>офіційну документацію</DocumentationLink>.",
"download": "Завантажити",
"website": "Вебсайт"
"title": "Платний хостинг"
}
}

28
docs/README-ko.md vendored
View File

@@ -263,19 +263,23 @@ docs](https://github.com/TriliumNext/Trilium/tree/main/docs/Developer%20Guide/De
* [CKEditor 5](https://github.com/ckeditor/ckeditor5) - 텍스트 노트의 시각적 편집기입니다. 프리미엄
기능을 제공해주셔서 감사합니다.
* [CodeMirror](https://github.com/codemirror/CodeMirror) - 수많은 언어를 지원하는 코드 편집기.
* [Excalidraw](https://github.com/excalidraw/excalidraw) - Canvas 노트에서 사용되는 무한
화이트보드입니다.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - 마인드맵 기능을 제공합니다.
* [Leaflet](https://github.com/Leaflet/Leaflet) - 지리 지도를 렌더링 합니다.
* [Tabulator](https://github.com/olifolkerd/tabulator) - 컬렉션에서 사용되는 인터랙티브
테이블입니다.
* [FancyTree](https://github.com/mar10/fancytree) - 독보적으로 기능이 풍부한 트리 라이브러리입니다.
* [jsPlumb](https://github.com/jsplumb/jsplumb) - 시각적 연결 라이브러리입니다. [관계
](https://docs.triliumnotes.org/user-guide/note-types/relation-map) 과 [링크
](https://docs.triliumnotes.org/user-guide/advanced-usage/note-map#link-map)에
사용됩니다
* [Excalidraw](https://github.com/excalidraw/excalidraw) - the infinite
whiteboard used in Canvas notes.
* [Mind Elixir](https://github.com/SSShooter/mind-elixir-core) - providing the
mind map functionality.
* [Leaflet](https://github.com/Leaflet/Leaflet) - for rendering geographical
maps.
* [Tabulator](https://github.com/olifolkerd/tabulator) - for the interactive
table used in collections.
* [FancyTree](https://github.com/mar10/fancytree) - feature-rich tree library
without real competition.
* [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
work](https://github.com/TriliumNext/Trilium/graphs/commit-activity). Your

46
docs/README-uk.md vendored
View File

@@ -95,8 +95,8 @@ Trilium Notes — це безкоштовний кросплатформний
безпечнішого входу
* [Синхронізація](https://docs.triliumnotes.org/user-guide/setup/synchronization)
із власним сервером синхронізації
* існують [сторонні сервіси для розміщення сервера
синхронізації](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* 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/advanced-usage/sharing)
(публікація) нотаток у загальнодоступному інтернеті
@@ -105,11 +105,10 @@ Trilium Notes — це безкоштовний кросплатформний
з деталізацією для кожної нотатки
* Створення ескізних схем на основі [Excalidraw](https://excalidraw.com/) (тип
нотатки "полотно")
* [Карти
зв'язків](https://docs.triliumnotes.org/user-guide/note-types/relation-map) та
[карти
нотаток/посилань](https://docs.triliumnotes.org/user-guide/note-types/note-map)
для візуалізації нотаток та їх зв'язків
* [Relation
maps](https://docs.triliumnotes.org/user-guide/note-types/relation-map) and
[note/link maps](https://docs.triliumnotes.org/user-guide/note-types/note-map)
for visualizing notes and their relations
* Інтелект-карти, засновані на [Mind Elixir](https://docs.mind-elixir.com/)
* [Геокарти](https://docs.triliumnotes.org/user-guide/collections/geomap) з
географічними позначками та GPX-треками
@@ -149,18 +148,19 @@ TriliumNext:
надав репозиторій Trilium спільнотному проекту, який знаходиться за адресою
https://github.com/TriliumNext
### ⬆️Переходите із Zadam/Trilium?
### ⬆️Migrating from Zadam/Trilium?
Немає жодних спеціальних кроків для міграції з екземпляра zadam/Trilium до
екземпляра TriliumNext/Trilium. Просто [встановіть
TriliumNext/Trilium](#-installation) як завжди, і він використовуватиме вашу
існуючу базу даних.
There are no special migration steps to migrate from a zadam/Trilium instance to
a TriliumNext/Trilium instance. Simply [install
TriliumNext/Trilium](#-installation) as usual and it will use your existing
database.
Версії до [v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4)
включно сумісні з останньою версією zadam/trilium
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Будь-які
пізніші версії TriliumNext/Trilium мають збільшені версії синхронізації, що
запобігає прямій міграції.
Versions up to and including
[v0.90.4](https://github.com/TriliumNext/Trilium/releases/tag/v0.90.4) are
compatible with the latest zadam/trilium version of
[v0.63.7](https://github.com/zadam/trilium/releases/tag/v0.63.7). Any later
versions of TriliumNext/Trilium have their sync versions incremented which
prevents direct migration.
## Обговоріть це з нами
@@ -189,8 +189,8 @@ TriliumNext/Trilium](#-installation) як завжди, і він викорис
Якщо ваш дистрибутив зазначено в таблиці нижче, використовуйте пакет вашого
дистрибутива.
[![Стан
упаковки](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
[![Packaging
status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
Ви також можете завантажити бінарний реліз для вашої платформи зі сторінки
[останнього релізу](https://github.com/TriliumNext/Trilium/releases/latest),
@@ -281,10 +281,10 @@ pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
### Документація розробника
Будь ласка, перегляньте
[документацію](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
для отримання детальної інформації. Якщо у вас виникнуть додаткові запитання,
звертайтеся до нас за посиланнями, описаними в розділі «Обговоріть з нами» вище.
Please view the [documentation
guide](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.
## 👏 Привітання

375
pnpm-lock.yaml generated
View File

@@ -182,6 +182,9 @@ importers:
apps/client:
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':
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)
@@ -266,9 +269,6 @@ importers:
'@zumer/snapdom':
specifier: 2.1.0
version: 2.1.0
autocomplete.js:
specifier: 0.38.1
version: 0.38.1
bootstrap:
specifier: 5.3.8
version: 5.3.8(@popperjs/core@2.11.8)
@@ -342,8 +342,8 @@ importers:
specifier: 10.29.0
version: 10.29.0
react-i18next:
specifier: 16.5.8
version: 16.5.8(i18next@25.8.17(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
specifier: 16.5.6
version: 16.5.6(i18next@25.8.17(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
react-window:
specifier: 2.2.7
version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -888,8 +888,8 @@ importers:
specifier: 6.6.6
version: 6.6.6(preact@10.29.0)
react-i18next:
specifier: 16.5.8
version: 16.5.8(i18next@25.8.17(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
specifier: 16.5.6
version: 16.5.6(i18next@25.8.17(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
devDependencies:
'@preact/preset-vite':
specifier: 2.10.3
@@ -1530,6 +1530,88 @@ packages:
rollup:
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':
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
@@ -7522,6 +7604,10 @@ packages:
ajv@8.17.1:
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:
resolution: {integrity: sha512-itUAVzhczTmP2U5yX67xVpsbbOiquusbWVyA9N+sy6+r6YVbFkahXvNCeEPWEOMhwDYwbVbGHFkVL03N9I5g+Q==}
@@ -7726,9 +7812,6 @@ packages:
resolution: {integrity: sha512-KbWgR8wOYRAPekEmMXrYYdc7BRyhn2Ftk7KWfMUnQ43hFdojWEFRxhhRUm3/OFEdPa1r0KAvTTg9YQK57xTe0g==}
engines: {node: '>=0.8'}
autocomplete.js@0.38.1:
resolution: {integrity: sha512-6pSJzuRMY3pqpozt+SXThl2DmJfma8Bi3SVFbZHS0PW/N72bOUv+Db0jAh2cWOhTsA4X+GNmKvIl8wExJTnN9w==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -10462,6 +10545,9 @@ packages:
hpack.js@2.1.6:
resolution: {integrity: sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==}
htm@3.1.1:
resolution: {integrity: sha512-983Vyg8NwUE7JkZ6NmOqpCZ+sh1bKv2iYTlUkzlWmA5JD2acKoxd4KVxbMmxX/85mtfdnDmTFoNKcg5DGAvxNQ==}
html-encoding-sniffer@2.0.1:
resolution: {integrity: sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==}
engines: {node: '>=10'}
@@ -10674,9 +10760,6 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immediate@3.3.0:
resolution: {integrity: sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==}
immutable@4.3.7:
resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==}
@@ -13757,8 +13840,8 @@ packages:
react: '>= 16.3.0'
react-dom: '>= 16.3.0'
react-i18next@16.5.8:
resolution: {integrity: sha512-2ABeHHlakxVY+LSirD+OiERxFL6+zip0PaHo979bgwzeHg27Sqc82xxXWIrSFmfWX0ZkrvXMHwhsi/NGUf5VQg==}
react-i18next@16.5.6:
resolution: {integrity: sha512-Ua7V2/efA88ido7KyK51fb8Ki8M/sRfW8LR/rZ/9ZKr2luhuTI7kwYZN5agT1rWG7aYm5G0RYE/6JR8KJoCMDw==}
peerDependencies:
i18next: '>= 25.6.2'
react: '>= 16.8.0'
@@ -14406,6 +14489,9 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
search-insights@2.17.3:
resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==}
secure-compare@3.0.1:
resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==}
@@ -16489,6 +16575,130 @@ snapshots:
optionalDependencies:
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':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
@@ -17143,6 +17353,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.4.0
'@ckeditor/ckeditor5-upload': 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)':
dependencies:
@@ -17283,12 +17495,16 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-cloud-services@47.4.0':
dependencies:
'@ckeditor/ckeditor5-core': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-code-block@47.4.0(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
dependencies:
@@ -17300,8 +17516,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-collaboration-core@47.4.0':
dependencies:
@@ -17481,6 +17695,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-classic@47.4.0':
dependencies:
@@ -17490,6 +17706,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-decoupled@47.4.0':
dependencies:
@@ -17499,6 +17717,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-inline@47.4.0':
dependencies:
@@ -17532,8 +17752,6 @@ snapshots:
'@ckeditor/ckeditor5-table': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-emoji@47.4.0':
dependencies:
@@ -17590,8 +17808,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-export-word@47.4.0':
dependencies:
@@ -17616,6 +17832,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-font@47.4.0':
dependencies:
@@ -17690,6 +17908,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-embed@47.4.0':
dependencies:
@@ -17749,8 +17969,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-indent@47.4.0':
dependencies:
@@ -17874,8 +18092,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-merge-fields@47.4.0':
dependencies:
@@ -17888,8 +18104,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-minimap@47.4.0':
dependencies:
@@ -17898,8 +18112,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-operations-compressor@47.4.0':
dependencies:
@@ -17954,8 +18166,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-pagination@47.4.0':
dependencies:
@@ -18063,8 +18273,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-slash-command@47.4.0':
dependencies:
@@ -18077,8 +18285,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-source-editing-enhanced@47.4.0':
dependencies:
@@ -18126,8 +18332,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-table@47.4.0':
dependencies:
@@ -18140,8 +18344,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-template@47.4.0':
dependencies:
@@ -18252,8 +18454,6 @@ snapshots:
'@ckeditor/ckeditor5-engine': 47.4.0
'@ckeditor/ckeditor5-utils': 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-widget@47.4.0':
dependencies:
@@ -18273,8 +18473,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.4.0
ckeditor5: 47.4.0
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@codemirror/autocomplete@6.18.6':
dependencies:
@@ -20820,7 +21018,7 @@ snapshots:
'@radix-ui/primitive@1.0.0':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/primitive@1.1.1': {}
@@ -20846,7 +21044,7 @@ snapshots:
'@radix-ui/react-collection@1.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
'@radix-ui/react-context': 1.0.0(react@19.2.4)
'@radix-ui/react-primitive': 1.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -20868,7 +21066,7 @@ snapshots:
'@radix-ui/react-compose-refs@1.0.0(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
react: 19.2.4
'@radix-ui/react-compose-refs@1.1.1(@types/react@19.1.7)(react@19.2.4)':
@@ -20885,7 +21083,7 @@ snapshots:
'@radix-ui/react-context@1.0.0(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
react: 19.2.4
'@radix-ui/react-context@1.1.1(@types/react@19.1.7)(react@19.2.4)':
@@ -20924,7 +21122,7 @@ snapshots:
'@radix-ui/react-direction@1.0.0(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
react: 19.2.4
'@radix-ui/react-direction@1.1.1(@types/react@19.1.7)(react@19.2.4)':
@@ -21027,7 +21225,7 @@ snapshots:
'@radix-ui/react-id@1.0.0(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4)
react: 19.2.4
@@ -21175,7 +21373,7 @@ snapshots:
'@radix-ui/react-presence@1.0.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
'@radix-ui/react-use-layout-effect': 1.0.0(react@19.2.4)
react: 19.2.4
@@ -21203,7 +21401,7 @@ snapshots:
'@radix-ui/react-primitive@1.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/react-slot': 1.0.1(react@19.2.4)
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
@@ -21237,7 +21435,7 @@ snapshots:
'@radix-ui/react-roving-focus@1.0.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/primitive': 1.0.0
'@radix-ui/react-collection': 1.0.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
@@ -21278,7 +21476,7 @@ snapshots:
'@radix-ui/react-slot@1.0.1(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/react-compose-refs': 1.0.0(react@19.2.4)
react: 19.2.4
@@ -21319,7 +21517,7 @@ snapshots:
'@radix-ui/react-use-callback-ref@1.0.0(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
react: 19.2.4
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.1.7)(react@19.2.4)':
@@ -21336,7 +21534,7 @@ snapshots:
'@radix-ui/react-use-controllable-state@1.0.0(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@radix-ui/react-use-callback-ref': 1.0.0(react@19.2.4)
react: 19.2.4
@@ -21378,7 +21576,7 @@ snapshots:
'@radix-ui/react-use-layout-effect@1.0.0(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
react: 19.2.4
'@radix-ui/react-use-layout-effect@1.1.0(@types/react@19.1.7)(react@19.2.4)':
@@ -21427,7 +21625,7 @@ snapshots:
'@rc-component/portal@1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
classnames: 2.5.1
rc-util: 5.44.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
@@ -21435,7 +21633,7 @@ snapshots:
'@rc-component/trigger@2.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@rc-component/portal': 1.1.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
classnames: 2.5.1
rc-motion: 2.9.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -25140,6 +25338,23 @@ snapshots:
json-schema-traverse: 1.0.0
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: {}
amator@1.1.0:
@@ -25374,10 +25589,6 @@ snapshots:
author-regex@1.0.0: {}
autocomplete.js@0.38.1:
dependencies:
immediate: 3.3.0
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -27090,7 +27301,7 @@ snapshots:
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
csstype: 3.2.3
dom-serialize@2.2.1:
@@ -28943,7 +29154,7 @@ snapshots:
history@4.10.1:
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
loose-envify: 1.4.0
resolve-pathname: 3.0.0
tiny-invariant: 1.3.3
@@ -28977,6 +29188,8 @@ snapshots:
readable-stream: 2.3.8
wbuf: 1.7.3
htm@3.1.1: {}
html-encoding-sniffer@2.0.1:
dependencies:
whatwg-encoding: 1.0.5
@@ -29245,8 +29458,6 @@ snapshots:
immediate@3.0.6: {}
immediate@3.3.0: {}
immutable@4.3.7: {}
immutable@5.1.4:
@@ -29775,7 +29986,7 @@ snapshots:
json-schema-to-ts@2.7.2:
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@types/json-schema': 7.0.15
ts-algebra: 1.2.2
@@ -31974,7 +32185,7 @@ snapshots:
polished@4.3.1:
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
portfinder@1.0.36:
dependencies:
@@ -32623,7 +32834,7 @@ snapshots:
rc-dropdown@4.2.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@rc-component/trigger': 2.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
classnames: 2.5.1
rc-util: 5.44.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -32632,7 +32843,7 @@ snapshots:
rc-menu@9.16.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@rc-component/trigger': 2.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
classnames: 2.5.1
rc-motion: 2.9.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -32643,7 +32854,7 @@ snapshots:
rc-motion@2.9.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
classnames: 2.5.1
rc-util: 5.44.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
@@ -32651,7 +32862,7 @@ snapshots:
rc-overflow@1.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
classnames: 2.5.1
rc-resize-observer: 1.4.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rc-util: 5.44.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -32660,7 +32871,7 @@ snapshots:
rc-resize-observer@1.4.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
classnames: 2.5.1
rc-util: 5.44.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react: 19.2.4
@@ -32669,14 +32880,14 @@ snapshots:
rc-util@5.44.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react-is: 18.3.1
rc-virtual-list@3.19.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
classnames: 2.5.1
rc-resize-observer: 1.4.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
rc-util: 5.44.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -32697,7 +32908,7 @@ snapshots:
react-beautiful-dnd@13.1.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
css-box-model: 1.2.1
memoize-one: 5.2.1
raf-schd: 4.0.3
@@ -32740,9 +32951,9 @@ snapshots:
react-resizable: 3.1.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
resize-observer-polyfill: 1.5.1
react-i18next@16.5.8(i18next@25.8.17(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
react-i18next@16.5.6(i18next@25.8.17(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
html-parse-stringify: 3.0.1
i18next: 25.8.17(typescript@5.9.3)
react: 19.2.4
@@ -32766,7 +32977,7 @@ snapshots:
react-redux@7.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
'@types/react-redux': 7.1.34
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
@@ -32841,7 +33052,7 @@ snapshots:
react-transition-group@4.4.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
@@ -32963,7 +33174,7 @@ snapshots:
redux@4.2.1:
dependencies:
'@babel/runtime': 7.28.6
'@babel/runtime': 7.28.4
reflect.getprototypeof@1.0.10:
dependencies:
@@ -33508,6 +33719,8 @@ snapshots:
scule@1.3.0: {}
search-insights@2.17.3: {}
secure-compare@3.0.1: {}
selderee@0.11.0: