Compare commits

...

52 Commits

Author SHA1 Message Date
renovate[bot]
cf47cec10a chore(deps): update dependency electron to v40.8.2 2026-03-13 05:45:35 +00:00
Elian Doran
702e29bd8c chore(deps): update vitest monorepo to v4.1.0 (#9041) 2026-03-13 07:41:10 +02:00
Elian Doran
27ac3e58c5 fix(deps): update dependency sqlite3 to v6 (#9044) 2026-03-13 07:40:07 +02:00
Elian Doran
86e268c06d Translations update from Hosted Weblate (#9046) 2026-03-13 07:39:12 +02:00
Ulices
6e4b231319 Translated using Weblate (Spanish)
Currently translated at 99.2% (1681 of 1693 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/es/
2026-03-13 05:09:49 +01:00
green
041eff6cbd Translated using Weblate (Japanese)
Currently translated at 100.0% (1693 of 1693 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2026-03-13 05:09:48 +01:00
renovate[bot]
1a3471a516 fix(deps): update dependency sqlite3 to v6 2026-03-13 01:10:11 +00:00
renovate[bot]
57c8727bb1 chore(deps): update vitest monorepo to v4.1.0 2026-03-13 01:06:52 +00:00
Elian Doran
a1bf7bfa08 Protected note tweaks (#9033) 2026-03-12 22:24:10 +02:00
Elian Doran
2a67c93c20 fix(deps): update dependency mathlive to v0.109.0 (#9024) 2026-03-12 21:30:13 +02:00
Elian Doran
b51bfdfb33 chore(client): address requested change 2026-03-12 21:09:30 +02:00
Elian Doran
9aa84877ee fix(tree): not reacting to protected state changes 2026-03-12 21:03:12 +02:00
Elian Doran
9e99670b19 fix(collections): displaying note list even if session is not unlocked 2026-03-12 20:53:19 +02:00
Elian Doran
744b93dd98 fix(board): does not respect protected note of parent 2026-03-12 20:50:56 +02:00
Elian Doran
5abb77242c feat(map): create pins atomically 2026-03-12 20:49:34 +02:00
Elian Doran
4ab3b0dd2b fix(map): does not respect protected note of parent 2026-03-12 20:47:43 +02:00
Elian Doran
a6a1594265 fix(table): does not respect protected note of parent 2026-03-12 20:44:25 +02:00
Elian Doran
b06cdd442d fix(calendar): does not respect protected note of parent 2026-03-12 20:41:53 +02:00
renovate[bot]
cf0f5ba4c4 fix(deps): update dependency mathlive to v0.109.0 2026-03-12 01:25:15 +00:00
Elian Doran
caa428c1a2 Translations update from Hosted Weblate (#8990) 2026-03-11 21:44:21 +02:00
Hosted Weblate
517c721664 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/
2026-03-11 20:39:34 +01:00
green
a8cdaa69f7 Translated using Weblate (Japanese)
Currently translated at 100.0% (1693 of 1693 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ja/
2026-03-11 20:39:33 +01:00
Luk On
53d221ef34 Translated using Weblate (Polish)
Currently translated at 100.0% (1676 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/pl/
2026-03-11 20:39:33 +01:00
pythaac
5450fde472 Translated using Weblate (Korean)
Currently translated at 93.1% (108 of 116 strings)

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/ko/
2026-03-11 20:39:33 +01:00
pythaac
808446cef5 Translated using Weblate (Korean)
Currently translated at 100.0% (158 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/ko/
2026-03-11 20:39:33 +01:00
ibs-allaow
921c663199 Translated using Weblate (Arabic)
Currently translated at 57.2% (959 of 1676 strings)

Translation: Trilium Notes/Client
Translate-URL: https://hosted.weblate.org/projects/trilium/client/ar/
2026-03-11 20:39:33 +01:00
Микола Копитін
1b8a75b615 Translated using Weblate (Ukrainian)
Currently translated at 98.2% (114 of 116 strings)

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/uk/
2026-03-11 20:39:33 +01:00
Микола Копитін
f78ced5bc3 Translated using Weblate (Ukrainian)
Currently translated at 99.3% (157 of 158 strings)

Translation: Trilium Notes/Website
Translate-URL: https://hosted.weblate.org/projects/trilium/website/uk/
2026-03-11 20:39:33 +01:00
JYC333
81bf5f4f3b Translated using Weblate (Swedish)
Currently translated at 17.6% (21 of 119 strings)

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/sv/
2026-03-11 20:39:33 +01:00
Hosted Weblate
aaed368670 Update translation files
Updated by "Cleanup translation files" add-on in Weblate.

Translation: Trilium Notes/README
Translate-URL: https://hosted.weblate.org/projects/trilium/readme/
2026-03-11 20:39:33 +01:00
Elian Doran
5e8de14721 Audio player improvements (#9008) 2026-03-11 21:38:58 +02:00
Elian Doran
634ab5b5c0 chore(media): address requested changes 2026-03-11 21:26:24 +02:00
Elian Doran
906889a035 fix(video): auto-hide no longer working 2026-03-11 21:07:48 +02:00
Elian Doran
ab9d50b905 feat(video): add blur for controls 2026-03-11 21:06:08 +02:00
Elian Doran
e61b7c7cfc feat(audio): add background effects 2026-03-11 20:58:49 +02:00
Elian Doran
1c628fba4c fix(video): playing button not working 2026-03-11 20:53:08 +02:00
Elian Doran
f8b4c6cb15 fix(audio): styling on light theme 2026-03-11 20:41:37 +02:00
Elian Doran
3edd8f6c5a chore(media): solve linter warnings 2026-03-11 20:38:53 +02:00
Elian Doran
7777f72893 chore(media): change translations prefix 2026-03-11 20:36:51 +02:00
Elian Doran
9af85b767b feat(audio): add an icon placeholder 2026-03-11 20:35:41 +02:00
Elian Doran
73260b91eb feat(audio): add mime to unsupported format 2026-03-11 20:29:34 +02:00
Elian Doran
2858f63873 feat(audio): report unsupported format 2026-03-11 19:30:12 +02:00
Elian Doran
15ca328727 feat(audio): make player full-width 2026-03-11 19:26:17 +02:00
Elian Doran
5b3fbecc0f feat(audio): introduce keyboard shortcuts 2026-03-11 19:24:47 +02:00
Elian Doran
365d0f0aac feat(audio): introduce playback speed 2026-03-11 19:17:14 +02:00
Elian Doran
e86d84c463 feat(audio): introduce loop button 2026-03-11 19:14:52 +02:00
Elian Doran
6b974c2ac7 feat(audio): introduce skip buttons 2026-03-11 19:12:09 +02:00
Elian Doran
d2afcbb98d feat(audio): introduce volume slider 2026-03-11 19:10:18 +02:00
Elian Doran
68a122fcf5 chore(audio): reintroduce some styles 2026-03-11 19:06:59 +02:00
Elian Doran
92f0144b48 feat(audio): reintroduce seek bar 2026-03-11 19:03:09 +02:00
Elian Doran
a5a345728c feat(audio): reintroduce play button 2026-03-11 18:56:43 +02:00
Elian Doran
23890e64e9 refactor(audio): extract to separate file 2026-03-11 18:51:20 +02:00
39 changed files with 1143 additions and 1051 deletions

View File

@@ -381,6 +381,10 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded">
// Collections must always display a note list, even if no children.
if (note.type === "book") {
if (note.isProtected && !protectedSessionHolder.isProtectedSessionAvailable()) {
return false;
}
const viewType = note.getLabelValue("viewType") ?? "grid";
if (!["list", "grid"].includes(viewType)) {
return true;

View File

@@ -110,7 +110,12 @@ function processNoteChange(loadResults: LoadResults, ec: EntityChange) {
}
}
if (ec.componentId) {
// Only register as a content change if the protection status didn't change.
// When isProtected changes, the blobId change is a side effect of re-encryption,
// not a content edit. Registering it as content would cause the tree's content-only
// filter to incorrectly skip the note update (since both changes share the same
// componentId).
if (ec.componentId && note.isProtected === (ec.entity as FNoteRow).isProtected) {
loadResults.addNoteContent(note.noteId, ec.componentId);
}
}

View File

@@ -1,15 +1,17 @@
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import { AttributeRow } from "@triliumnext/commons";
import appContext from "../components/app_context.js";
import type FBranch from "../entities/fbranch.js";
import type FNote from "../entities/fnote.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import froca from "./froca.js";
import { t } from "./i18n.js";
import protectedSessionHolder from "./protected_session_holder.js";
import server from "./server.js";
import ws from "./ws.js";
import froca from "./froca.js";
import treeService from "./tree.js";
import toastService from "./toast.js";
import { t } from "./i18n.js";
import type FNote from "../entities/fnote.js";
import type FBranch from "../entities/fbranch.js";
import type { ChooseNoteTypeResponse } from "../widgets/dialogs/note_type_chooser.js";
import type { CKTextEditor } from "@triliumnext/ckeditor5";
import treeService from "./tree.js";
import ws from "./ws.js";
export interface CreateNoteOpts {
isProtected?: boolean;
@@ -24,6 +26,8 @@ export interface CreateNoteOpts {
target?: string;
targetBranchId?: string;
textEditor?: CKTextEditor;
/** Attributes to be set on the note. These are set atomically on note creation, so entity changes are not sent for attributes defined here. */
attributes?: Omit<AttributeRow, "noteId" | "attributeId">[];
}
interface Response {
@@ -37,7 +41,7 @@ interface DuplicateResponse {
note: FNote;
}
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}) {
async function createNote(parentNotePath: string | undefined, options: CreateNoteOpts = {}, componentId?: string) {
options = Object.assign(
{
activate: true,
@@ -77,8 +81,9 @@ async function createNote(parentNotePath: string | undefined, options: CreateNot
isProtected: options.isProtected,
type: options.type,
mime: options.mime,
templateNoteId: options.templateNoteId
});
templateNoteId: options.templateNoteId,
attributes: options.attributes
}, componentId);
if (options.saveSelection) {
// we remove the selection only after it was saved to server to make sure we don't lose anything
@@ -140,9 +145,8 @@ function parseSelectedHtml(selectedHtml: string) {
const content = selectedHtml.replace(dom[0].outerHTML, "");
return [title, content];
} else {
return [null, selectedHtml];
}
return [null, selectedHtml];
}
async function duplicateSubtree(noteId: string, parentNotePath: string) {

View File

@@ -803,12 +803,13 @@
"web-view": "عرض الويب",
"mind-map": "خريطة ذهنية",
"geo-map": "خريطة جغرافية",
"task-list": "قائمة المهام"
"task-list": "قائمة المهام",
"spreadsheet": "جدول البيانات"
},
"shared_switch": {
"shared": "مشترك",
"toggle-on-title": "مشاركة الملاحظة",
"toggle-off-title": "الغاء مشاركة الملاحظة"
"toggle-off-title": "إلغاء مشاركة الملاحظة"
},
"template_switch": {
"template": "قالب"
@@ -1286,8 +1287,10 @@
"search-for": "بحث ل \"{{term}}\""
},
"protect_note": {
"toggle-off": "ازالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة"
"toggle-off": "إزالة الحماية عن الملاحظة",
"toggle-on": "حماية الملاحظة",
"toggle-on-hint": "الملاحظة غير محمة، انقر لحمايتها",
"toggle-off-hint": "الملاحظة محمية، انقر لإزالة الحماية منها"
},
"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."
},
"video": {
"media": {
"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": "Video preview is not available for this file format.",
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
"zoom-to-fit": "Zoom to fill",
"zoom-reset": "Reset zoom to fill"
},

View File

@@ -2198,5 +2198,12 @@
},
"setup_form": {
"more_info": "Para saber más"
},
"media": {
"play": "Reproducir (Espacio)",
"pause": "Pausa (Espacio)",
"back-10s": "Retroceder 10s (tecla de flecha izquierda)",
"forward-30s": "Adelantar 30s",
"mute": "Silenciar (M)"
}
}

View File

@@ -2168,5 +2168,24 @@
},
"setup_form": {
"more_info": "さらに詳しく"
},
"media": {
"play": "再生 (スペース)",
"pause": "一時停止 (スペース)",
"back-10s": "10 秒戻る (左矢印キー)",
"forward-30s": "30 秒進む",
"mute": "ミュート (M)",
"unmute": "ミュート解除 (M)",
"playback-speed": "再生速度",
"loop": "ループ",
"disable-loop": "ループを解除",
"rotate": "回転",
"picture-in-picture": "ピクチャーインピクチャー",
"exit-picture-in-picture": "ピクチャーインピクチャーを終了",
"fullscreen": "全画面表示 (F)",
"exit-fullscreen": "全画面表示を終了",
"unsupported-format": "このファイル形式ではメディアプレビューはご利用いただけません:\n{{mime}}",
"zoom-to-fit": "ズームして全体を表示",
"zoom-reset": "ズーム設定をリセット"
}
}

View File

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

View File

@@ -1,5 +1,5 @@
import { BulkAction } from "@triliumnext/commons";
import { BoardViewData } from ".";
import appContext from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import attributes from "../../../services/attributes";
@@ -9,6 +9,7 @@ import froca from "../../../services/froca";
import { t } from "../../../services/i18n";
import note_create from "../../../services/note_create";
import server from "../../../services/server";
import { BoardViewData } from ".";
import { ColumnMap } from "./data";
export default class BoardApi {
@@ -35,13 +36,11 @@ export default class BoardApi {
async createNewItem(column: string, title: string) {
try {
// Get the parent note path
const parentNotePath = this.parentNote.noteId;
// Create a new note as a child of the parent note
const { note: newNote, branch: newBranch } = await note_create.createNote(parentNotePath, {
const { note: newNote, branch: newBranch } = await note_create.createNote(this.parentNote.noteId, {
activate: false,
title
title,
isProtected: this.parentNote.isProtected
});
if (newNote && newBranch) {
@@ -87,7 +86,7 @@ export default class BoardApi {
const action: BulkAction = this.isRelationMode
? { name: "deleteRelation", relationName: this.statusAttribute }
: { name: "deleteLabel", labelName: this.statusAttribute }
: { name: "deleteLabel", labelName: this.statusAttribute };
await executeBulkActions(noteIds, [ action ]);
this.viewConfig.columns = (this.viewConfig.columns ?? []).filter(col => col.value !== column);
this.saveConfig(this.viewConfig);
@@ -99,7 +98,7 @@ export default class BoardApi {
// Change the value in the notes.
const action: BulkAction = this.isRelationMode
? { name: "updateRelationTarget", relationName: this.statusAttribute, targetNoteId: newValue }
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue }
: { name: "updateLabelValue", labelName: this.statusAttribute, labelValue: newValue };
await executeBulkActions(noteIds, [ action ]);
// Rename the column in the persisted data.
@@ -137,9 +136,9 @@ export default class BoardApi {
}
async insertRowAtPosition(
column: string,
relativeToBranchId: string,
direction: "before" | "after") {
column: string,
relativeToBranchId: string,
direction: "before" | "after") {
const { note, branch } = await note_create.createNote(this.parentNote.noteId, {
activate: false,
targetBranchId: relativeToBranchId,
@@ -179,9 +178,8 @@ export default class BoardApi {
if (!note) return;
if (this.isRelationMode) {
return attributes.removeOwnedRelationByName(note, this.statusAttribute);
} else {
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
}
return attributes.removeOwnedLabelByName(note, this.statusAttribute);
}
async moveWithinBoard(noteId: string, sourceBranchId: string, sourceIndex: number, targetIndex: number, sourceColumn: string, targetColumn: string) {

View File

@@ -1,8 +1,8 @@
import { AttributeRow, CreateChildrenResponse } from "@triliumnext/commons";
import { AttributeRow } from "@triliumnext/commons";
import FNote from "../../../entities/fnote";
import { setAttribute, setLabel } from "../../../services/attributes";
import server from "../../../services/server";
import note_create from "../../../services/note_create";
interface NewEventOpts {
title: string;
@@ -51,11 +51,13 @@ export async function newEvent(parentNote: FNote, { title, startDate, endDate, s
}
// Create the note.
await server.post<CreateChildrenResponse>(`notes/${parentNote.noteId}/children?target=into`, {
await note_create.createNote(parentNote.noteId, {
title,
isProtected: parentNote.isProtected,
content: "",
type: "text",
attributes
attributes,
activate: false
}, componentId);
}

View File

@@ -1,10 +1,11 @@
import type { LatLng, LeafletMouseEvent } from "leaflet";
import { LOCATION_ATTRIBUTE } from ".";
import FNote from "../../../entities/fnote";
import attributes from "../../../services/attributes";
import { prompt } from "../../../services/dialog";
import server from "../../../services/server";
import { t } from "../../../services/i18n";
import { CreateChildrenResponse } from "@triliumnext/commons";
import note_create from "../../../services/note_create";
import { LOCATION_ATTRIBUTE } from ".";
const CHILD_NOTE_ICON = "bx bx-pin";
@@ -13,16 +14,20 @@ export async function moveMarker(noteId: string, latLng: LatLng | null) {
await attributes.setLabel(noteId, LOCATION_ATTRIBUTE, value);
}
export async function createNewNote(noteId: string, e: LeafletMouseEvent) {
export async function createNewNote(parentNote: FNote, e: LeafletMouseEvent) {
const title = await prompt({ message: t("relation_map.enter_title_of_new_note"), defaultValue: t("relation_map.default_new_note_title") });
if (title?.trim()) {
const { note } = await server.post<CreateChildrenResponse>(`notes/${noteId}/children?target=into`, {
await note_create.createNote(parentNote.noteId, {
title,
content: "",
type: "text"
type: "text",
activate: false,
isProtected: parentNote.isProtected,
attributes: [
{ type: "label", name: LOCATION_ATTRIBUTE, value: [e.latlng.lat, e.latlng.lng].join(",") },
{ type: "label", name: "iconClass", value: CHILD_NOTE_ICON }
]
});
attributes.setLabel(note.noteId, "iconClass", CHILD_NOTE_ICON);
moveMarker(note.noteId, e.latlng);
}
}

View File

@@ -1,12 +1,14 @@
import type { LatLng, LeafletMouseEvent } from "leaflet";
import appContext, { type CommandMappings } from "../../../components/app_context.js";
import FNote from "../../../entities/fnote.js";
import contextMenu, { type MenuItem } from "../../../menus/context_menu.js";
import linkContextMenu from "../../../menus/link_context_menu.js";
import NoteColorPicker from "../../../menus/custom-items/NoteColorPicker.jsx";
import { t } from "../../../services/i18n.js";
import { createNewNote } from "./api.js";
import linkContextMenu from "../../../menus/link_context_menu.js";
import { copyTextWithToast } from "../../../services/clipboard_ext.js";
import { t } from "../../../services/i18n.js";
import link from "../../../services/link.js";
import { createNewNote } from "./api.js";
export default function openContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
let items: MenuItem<keyof CommandMappings>[] = [
@@ -44,7 +46,7 @@ export default function openContextMenu(noteId: string, e: LeafletMouseEvent, is
});
}
export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEditable: boolean) {
export function openMapContextMenu(note: FNote, e: LeafletMouseEvent, isEditable: boolean) {
let items: MenuItem<keyof CommandMappings>[] = [
...buildGeoLocationItem(e)
];
@@ -55,10 +57,10 @@ export function openMapContextMenu(noteId: string, e: LeafletMouseEvent, isEdita
{ kind: "separator" },
{
title: t("geo-map-context.add-note"),
handler: () => createNewNote(noteId, e),
handler: () => createNewNote(note, e),
uiIcon: "bx bx-plus"
}
]
];
}
contextMenu.show({

View File

@@ -93,14 +93,14 @@ export default function GeoView({ note, noteIds, viewConfig, saveConfig }: ViewM
const onClick = useCallback(async (e: LeafletMouseEvent) => {
if (state === State.NewNote) {
toast.closePersistent("geo-new-note");
await createNewNote(note.noteId, e);
await createNewNote(note, e);
setState(State.Normal);
}
}, [ state ]);
}, [ note, state ]);
const onContextMenu = useCallback((e: LeafletMouseEvent) => {
openMapContextMenu(note.noteId, e, !isReadOnly);
}, [ note.noteId, isReadOnly ]);
openMapContextMenu(note, e, !isReadOnly);
}, [ note, isReadOnly ]);
// Dragging
const containerRef = useRef<HTMLDivElement>(null);

View File

@@ -18,14 +18,14 @@ import useRowTableEditing from "./row_editing";
import { TableData } from "./rows";
import Tabulator from "./tabulator";
export default function TableView({ note, noteIds, notePath, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
export default function TableView({ note, noteIds, viewConfig, saveConfig }: ViewModeProps<TableConfig>) {
const tabulatorRef = useRef<VanillaTabulator>(null);
const parentComponent = useContext(ParentComponent);
const [ attributeDetailWidgetEl, attributeDetailWidget ] = useLegacyWidget(() => new AttributeDetailWidget().contentSized());
const contextMenuEvents = useContextMenu(note, parentComponent, tabulatorRef);
const persistenceProps = usePersistence(viewConfig, saveConfig);
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, notePath);
const rowEditingEvents = useRowTableEditing(tabulatorRef, attributeDetailWidget, note);
const { newAttributePosition, resetNewAttributePosition } = useColTableEditing(tabulatorRef, attributeDetailWidget, note);
const { columnDefs, rowData, movableRows, hasChildren } = useData(note, noteIds, viewConfig, newAttributePosition, resetNewAttributePosition);
const dataTreeProps = useMemo<Options>(() => {

View File

@@ -1,24 +1,27 @@
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
import { CommandListenerData } from "../../../components/app_context";
import note_create, { CreateNoteOpts } from "../../../services/note_create";
import { useLegacyImperativeHandlers } from "../../react/hooks";
import { RefObject } from "preact";
import { setAttribute, setLabel } from "../../../services/attributes";
import froca from "../../../services/froca";
import server from "../../../services/server";
import branches from "../../../services/branches";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import { EventCallBackMethods, RowComponent, Tabulator } from "tabulator-tables";
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNotePath: string): Partial<EventCallBackMethods> {
import { CommandListenerData } from "../../../components/app_context";
import FNote from "../../../entities/fnote";
import { setAttribute, setLabel } from "../../../services/attributes";
import branches from "../../../services/branches";
import froca from "../../../services/froca";
import note_create, { CreateNoteOpts } from "../../../services/note_create";
import server from "../../../services/server";
import AttributeDetailWidget from "../../attribute_widgets/attribute_detail";
import { useLegacyImperativeHandlers } from "../../react/hooks";
export default function useRowTableEditing(api: RefObject<Tabulator>, attributeDetailWidget: AttributeDetailWidget, parentNote: FNote): Partial<EventCallBackMethods> {
// Adding new rows
useLegacyImperativeHandlers({
addNewRowCommand({ customOpts, parentNotePath: customNotePath }: CommandListenerData<"addNewRow">) {
const notePath = customNotePath ?? parentNotePath;
const notePath = customNotePath ?? parentNote.noteId;
if (notePath) {
const opts: CreateNoteOpts = {
activate: false,
isProtected: parentNote.isProtected,
...customOpts
}
};
note_create.createNote(notePath, opts).then(({ branch }) => {
if (branch) {
setTimeout(() => {
@@ -26,7 +29,7 @@ export default function useRowTableEditing(api: RefObject<Tabulator>, attributeD
focusOnBranch(api.current, branch?.branchId);
}, 100);
}
})
});
}
}
});
@@ -91,14 +94,14 @@ function focusOnBranch(api: Tabulator, branchId: string) {
}
function findRowDataById(rows: RowComponent[], branchId: string): RowComponent | null {
for (let row of rows) {
for (const row of rows) {
const item = row.getIndex() as string;
if (item === branchId) {
return row;
}
let found = findRowDataById(row.getTreeChildren(), branchId);
const found = findRowDataById(row.getTreeChildren(), branchId);
if (found) return found;
}
return null;

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/"))) {
if (note.type === "file" && (note.mime === "application/pdf" || note.mime.startsWith("video/") || note.mime.startsWith("audio/"))) {
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)) {
if (note.type === "file" && (MIME_TYPES_WITH_BACKGROUND_EFFECTS.includes(note.mime) || note.mime.startsWith("audio/"))) {
return true;
}

View File

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

View File

@@ -1,10 +1,9 @@
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";
@@ -43,16 +42,6 @@ function TextPreview({ content }: { content: string }) {
);
}
function AudioPreview({ note }: { note: FNote }) {
return (
<audio
class="audio-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
controls
/>
);
}
function NoPreview() {
return (
<Alert className="file-preview-not-available" type="info">

View File

@@ -0,0 +1,112 @@
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import Icon from "../../react/Icon";
import NoItems from "../../react/NoItems";
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
export default function AudioPreview({ note }: { note: FNote }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(false);
const togglePlayback = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.play();
} else {
audio.pause();
}
}, []);
const onKeyDown = useKeyboardShortcuts(audioRef, togglePlayback);
useEffect(() => setError(false), [note.noteId]);
const onError = useCallback(() => setError(true), []);
if (error) {
return <NoItems icon="bx bx-volume-mute" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
}
return (
<div ref={wrapperRef} className="audio-preview-wrapper" onKeyDown={onKeyDown} tabIndex={0}>
<audio
class="audio-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
ref={audioRef}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="audio-preview-icon-wrapper">
<Icon icon="bx bx-music" className="audio-preview-icon" />
</div>
<div className="media-preview-controls">
<SeekBar mediaRef={audioRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={audioRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={audioRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={audioRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={audioRef} />
</div>
<div className="right">
<VolumeControl mediaRef={audioRef} />
</div>
</div>
</div>
</div>
);
}
function useKeyboardShortcuts(audioRef: MutableRef<HTMLAudioElement | null>, togglePlayback: () => void) {
return useCallback((e: KeyboardEvent) => {
const audio = audioRef.current;
if (!audio) return;
switch (e.key) {
case " ":
e.preventDefault();
togglePlayback();
break;
case "ArrowLeft":
e.preventDefault();
audio.currentTime = Math.max(0, audio.currentTime - (e.ctrlKey ? 60 : 10));
break;
case "ArrowRight":
e.preventDefault();
audio.currentTime = Math.min(audio.duration, audio.currentTime + (e.ctrlKey ? 60 : 10));
break;
case "m":
case "M":
e.preventDefault();
audio.muted = !audio.muted;
break;
case "ArrowUp":
e.preventDefault();
audio.volume = Math.min(1, audio.volume + 0.05);
break;
case "ArrowDown":
e.preventDefault();
audio.volume = Math.max(0, audio.volume - 0.05);
break;
case "Home":
e.preventDefault();
audio.currentTime = 0;
break;
case "End":
e.preventDefault();
audio.currentTime = audio.duration;
break;
}
}, [ audioRef, togglePlayback ]);
}

View File

@@ -0,0 +1,98 @@
.media-preview-controls {
padding: 1.25em;
display: flex;
flex-direction: column;
gap: 0.5em;
.media-buttons-row {
display: flex;
> * {
flex: 1;
align-items: center;
gap: 0.5em;
display: flex;
}
.spacer {
width: var(--icon-button-size, 32px);
height: var(--icon-button-size, 32px);
}
.center {
justify-content: center;
}
.right {
display: flex;
justify-content: flex-end;
}
.play-button {
--icon-button-size: 48px;
}
}
.media-seekbar-row {
display: flex;
align-items: center;
gap: 0.5em;
.media-time {
font-size: 0.85em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.media-trackbar {
flex: 1;
cursor: pointer;
}
}
.media-volume-row {
display: flex;
align-items: center;
gap: 0.25em;
.media-volume-slider {
width: 80px;
cursor: pointer;
}
}
.speed-dropdown {
position: relative;
.tn-icon {
transform: translateY(-10%);
}
.media-speed-label {
position: absolute;
bottom: 0;
left: 0;
right: 0;
transform: translateY(15%);
text-align: center;
font-size: 0.6rem;
font-variant-numeric: tabular-nums;
}
}
}
.audio-preview-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.audio-preview-icon-wrapper {
flex-grow: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 8em;
opacity: 0.6;
}
}

View File

@@ -0,0 +1,220 @@
import "./MediaPlayer.css";
import { RefObject } from "preact";
import { useEffect, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import Icon from "../../react/Icon";
export function SeekBar({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
const onTimeUpdate = () => setCurrentTime(media.currentTime);
const onDurationChange = () => setDuration(media.duration);
media.addEventListener("timeupdate", onTimeUpdate);
media.addEventListener("durationchange", onDurationChange);
return () => {
media.removeEventListener("timeupdate", onTimeUpdate);
media.removeEventListener("durationchange", onDurationChange);
};
}, [ mediaRef ]);
const onSeek = (e: Event) => {
const media = mediaRef.current;
if (!media) return;
media.currentTime = parseFloat((e.target as HTMLInputElement).value);
};
return (
<div class="media-seekbar-row">
<span class="media-time">{formatTime(currentTime)}</span>
<input
type="range"
class="media-trackbar"
min={0}
max={duration || 0}
step={0.1}
value={currentTime}
onInput={onSeek}
/>
<span class="media-time">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
);
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
export function PlayPauseButton({ playing, togglePlayback }: {
playing: boolean,
togglePlayback: () => void
}) {
return (
<ActionButton
className="play-button"
icon={playing ? "bx bx-pause" : "bx bx-play"}
text={playing ? t("media.pause") : t("media.play")}
onClick={togglePlayback}
/>
);
}
export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [volume, setVolume] = useState(() => mediaRef.current?.volume ?? 1);
const [muted, setMuted] = useState(() => mediaRef.current?.muted ?? false);
// Sync state when the media element changes volume externally.
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setVolume(media.volume);
setMuted(media.muted);
const onVolumeChange = () => {
setVolume(media.volume);
setMuted(media.muted);
};
media.addEventListener("volumechange", onVolumeChange);
return () => media.removeEventListener("volumechange", onVolumeChange);
}, [ mediaRef ]);
const onVolumeChange = (e: Event) => {
const media = mediaRef.current;
if (!media) return;
const val = parseFloat((e.target as HTMLInputElement).value);
media.volume = val;
setVolume(val);
if (val > 0 && media.muted) {
media.muted = false;
setMuted(false);
}
};
const toggleMute = () => {
const media = mediaRef.current;
if (!media) return;
media.muted = !media.muted;
setMuted(media.muted);
};
return (
<div class="media-volume-row">
<ActionButton
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
text={muted ? t("media.unmute") : t("media.mute")}
onClick={toggleMute}
/>
<input
type="range"
class="media-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</div>
);
}
export function SkipButton({ mediaRef, seconds, icon, text }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement>, seconds: number, icon: string, text: string }) {
const skip = () => {
const media = mediaRef.current;
if (!media) return;
media.currentTime = Math.max(0, Math.min(media.duration, media.currentTime + seconds));
};
return (
<ActionButton icon={icon} text={text} onClick={skip} />
);
}
export function LoopButton({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [loop, setLoop] = useState(() => mediaRef.current?.loop ?? false);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setLoop(media.loop);
const observer = new MutationObserver(() => setLoop(media.loop));
observer.observe(media, { attributes: true, attributeFilter: ["loop"] });
return () => observer.disconnect();
}, [ mediaRef ]);
const toggle = () => {
const media = mediaRef.current;
if (!media) return;
media.loop = !media.loop;
setLoop(media.loop);
};
return (
<ActionButton
className={loop ? "active" : ""}
icon="bx bx-repeat"
text={loop ? t("media.disable-loop") : t("media.loop")}
onClick={toggle}
/>
);
}
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
export function PlaybackSpeed({ mediaRef }: { mediaRef: RefObject<HTMLVideoElement | HTMLAudioElement> }) {
const [speed, setSpeed] = useState(() => mediaRef.current?.playbackRate ?? 1);
useEffect(() => {
const media = mediaRef.current;
if (!media) return;
setSpeed(media.playbackRate);
const onRateChange = () => setSpeed(media.playbackRate);
media.addEventListener("ratechange", onRateChange);
return () => media.removeEventListener("ratechange", onRateChange);
}, [ mediaRef ]);
const selectSpeed = (rate: number) => {
const media = mediaRef.current;
if (!media) return;
media.playbackRate = rate;
setSpeed(rate);
};
return (
<Dropdown
iconAction
hideToggleArrow
buttonClassName="speed-dropdown"
text={<>
<Icon icon="bx bx-tachometer" />
<span class="media-speed-label">{speed}x</span>
</>}
title={t("media.playback-speed")}
>
{PLAYBACK_SPEEDS.map((rate) => (
<li key={rate}>
<button
class={`dropdown-item ${rate === speed ? "active" : ""}`}
onClick={() => selectSpeed(rate)}
>
{rate}x
</button>
</li>
))}
</Dropdown>
);
}

View File

@@ -2,7 +2,7 @@
width: 100%;
height: 100%;
position: relative;
background-color: black;
background-color: black;
.video-preview {
background-color: black;
@@ -13,102 +13,23 @@
&.controls-hidden {
cursor: pointer;
.video-preview-controls {
.media-preview-controls {
opacity: 0;
pointer-events: none;
}
}
.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);
color: white;
.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-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;
}
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
color: white;
}
}

View File

@@ -1,21 +1,14 @@
import "./Video.css";
import { RefObject } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { MutableRef, useCallback, useEffect, useRef, useState } from "preact/hooks";
import FNote from "../../../entities/fnote";
import { 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";
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
const AUTO_HIDE_DELAY = 3000;
@@ -40,11 +33,56 @@ export default function VideoPreview({ note }: { note: FNote }) {
}, []);
const onVideoClick = useCallback((e: MouseEvent) => {
if ((e.target as HTMLElement).closest(".video-preview-controls")) return;
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
togglePlayback();
}, [togglePlayback]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
if (error) {
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
}
return (
<div ref={wrapperRef} className={`video-preview-wrapper ${controlsVisible ? "" : "controls-hidden"}`} tabIndex={0} onClick={onVideoClick} onKeyDown={onKeyDown} onMouseMove={onMouseMove}>
<video
ref={videoRef}
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
/>
<div className="media-preview-controls">
<SeekBar mediaRef={videoRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={videoRef} />
<RotateButton videoRef={videoRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={videoRef} />
</div>
<div className="right">
<VolumeControl mediaRef={videoRef} />
<ZoomToFitButton videoRef={videoRef} />
<PictureInPictureButton videoRef={videoRef} />
<FullscreenButton targetRef={wrapperRef} />
</div>
</div>
</div>
</div>
);
}
function useKeyboardShortcuts(videoRef: MutableRef<HTMLVideoElement | null>, wrapperRef: MutableRef<HTMLDivElement | null>, togglePlayback: () => void, flashControls: () => void) {
return useCallback((e: KeyboardEvent) => {
const video = videoRef.current;
if (!video) return;
@@ -100,48 +138,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
flashControls();
break;
}
}, [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>
);
}, [ wrapperRef, videoRef, togglePlayback, flashControls ]);
}
function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boolean) {
@@ -153,7 +150,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);
@@ -174,219 +171,6 @@ 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);
@@ -414,7 +198,7 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
return (
<ActionButton
icon="bx bx-rotate-right"
text={t("video.rotate")}
text={t("media.rotate")}
onClick={rotate}
/>
);
@@ -435,7 +219,7 @@ function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }
<ActionButton
className={fitted ? "active" : ""}
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
text={fitted ? t("video.zoom-reset") : t("video.zoom-to-fit")}
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
onClick={toggle}
/>
);
@@ -460,7 +244,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
video.removeEventListener("enterpictureinpicture", onEnter);
video.removeEventListener("leavepictureinpicture", onLeave);
};
}, [supported]);
}, [ videoRef, supported ]);
if (!supported) return null;
@@ -478,7 +262,7 @@ function PictureInPictureButton({ videoRef }: { videoRef: RefObject<HTMLVideoEle
return (
<ActionButton
icon={active ? "bx bx-exit" : "bx bx-window-open"}
text={active ? t("video.exit-picture-in-picture") : t("video.picture-in-picture")}
text={active ? t("media.exit-picture-in-picture") : t("media.picture-in-picture")}
onClick={toggle}
/>
);
@@ -507,7 +291,7 @@ function FullscreenButton({ targetRef }: { targetRef: RefObject<HTMLElement> })
return (
<ActionButton
icon={isFullscreen ? "bx bx-exit-fullscreen" : "bx bx-fullscreen"}
text={isFullscreen ? t("video.exit-fullscreen") : t("video.fullscreen")}
text={isFullscreen ? t("media.exit-fullscreen") : t("media.fullscreen")}
onClick={toggleFullscreen}
/>
);

View File

@@ -7,7 +7,7 @@
"colors": "1.4.0",
"diff": "8.0.3",
"sqlite": "5.1.1",
"sqlite3": "5.1.7"
"sqlite3": "6.0.1"
},
"scripts": {
"dev": "tsx src/compare.ts",

View File

@@ -35,7 +35,7 @@
"@triliumnext/commons": "workspace:*",
"@triliumnext/server": "workspace:*",
"copy-webpack-plugin": "14.0.0",
"electron": "40.8.0",
"electron": "40.8.2",
"@electron-forge/cli": "7.11.1",
"@electron-forge/maker-deb": "7.11.1",
"@electron-forge/maker-dmg": "7.11.1",

View File

@@ -12,7 +12,7 @@
"@triliumnext/desktop": "workspace:*",
"@types/fs-extra": "11.0.4",
"copy-webpack-plugin": "14.0.0",
"electron": "40.8.0",
"electron": "40.8.2",
"fs-extra": "11.3.4"
},
"scripts": {

View File

@@ -82,7 +82,7 @@
"debounce": "3.0.0",
"debug": "4.4.3",
"ejs": "5.0.1",
"electron": "40.8.0",
"electron": "40.8.2",
"electron-debug": "4.1.0",
"electron-window-state": "5.0.3",
"escape-html": "1.0.3",

View File

@@ -23,7 +23,7 @@
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",
"vite": "7.3.1",
"vitest": "4.0.18"
"vitest": "4.1.0"
},
"eslintConfig": {
"extends": "preact"

View File

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

28
docs/README-ko.md vendored
View File

@@ -263,23 +263,19 @@ 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) - 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)
* [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)에
사용됩니다
## 🤝 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)
із власним сервером синхронізації
* there are [3rd party services for hosting synchronisation
server](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* існують [сторонні сервіси для розміщення сервера
синхронізації](https://docs.triliumnotes.org/user-guide/setup/server/cloud-hosting)
* [Спільне
використання](https://docs.triliumnotes.org/user-guide/advanced-usage/sharing)
(публікація) нотаток у загальнодоступному інтернеті
@@ -105,10 +105,11 @@ Trilium Notes — це безкоштовний кросплатформний
з деталізацією для кожної нотатки
* Створення ескізних схем на основі [Excalidraw](https://excalidraw.com/) (тип
нотатки "полотно")
* [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
* [Карти
зв'язків](https://docs.triliumnotes.org/user-guide/note-types/relation-map) та
[карти
нотаток/посилань](https://docs.triliumnotes.org/user-guide/note-types/note-map)
для візуалізації нотаток та їх зв'язків
* Інтелект-карти, засновані на [Mind Elixir](https://docs.mind-elixir.com/)
* [Геокарти](https://docs.triliumnotes.org/user-guide/collections/geomap) з
географічними позначками та GPX-треками
@@ -148,19 +149,18 @@ TriliumNext:
надав репозиторій Trilium спільнотному проекту, який знаходиться за адресою
https://github.com/TriliumNext
### ⬆️Migrating from Zadam/Trilium?
### ⬆️Переходите із Zadam/Trilium?
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.
Немає жодних спеціальних кроків для міграції з екземпляра zadam/Trilium до
екземпляра TriliumNext/Trilium. Просто [встановіть
TriliumNext/Trilium](#-installation) як завжди, і він використовуватиме вашу
існуючу базу даних.
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.
Версії до [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 мають збільшені версії синхронізації, що
запобігає прямій міграції.
## Обговоріть це з нами
@@ -189,8 +189,8 @@ prevents direct migration.
Якщо ваш дистрибутив зазначено в таблиці нижче, використовуйте пакет вашого
дистрибутива.
[![Packaging
status](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
[![Стан
упаковки](https://repology.org/badge/vertical-allrepos/triliumnext.svg)](https://repology.org/project/triliumnext/versions)
Ви також можете завантажити бінарний реліз для вашої платформи зі сторінки
[останнього релізу](https://github.com/TriliumNext/Trilium/releases/latest),
@@ -281,10 +281,10 @@ pnpm run --filter desktop electron-forge:make --arch=x64 --platform=win32
### Документація розробника
Please view the [documentation
guide](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
for details. If you have more questions, feel free to reach out via the links
described in the "Discuss with us" section above.
Будь ласка, перегляньте
[документацію](https://github.com/TriliumNext/Trilium/blob/main/docs/Developer%20Guide/Developer%20Guide/Environment%20Setup.md)
для отримання детальної інформації. Якщо у вас виникнуть додаткові запитання,
звертайтеся до нас за посиланнями, описаними в розділі «Обговоріть з нами» вище.
## 👏 Привітання

View File

@@ -51,9 +51,9 @@
"@types/express": "5.0.6",
"@types/js-yaml": "4.0.9",
"@types/node": "24.12.0",
"@vitest/browser-webdriverio": "4.0.18",
"@vitest/coverage-v8": "4.0.18",
"@vitest/ui": "4.0.18",
"@vitest/browser-webdriverio": "4.1.0",
"@vitest/coverage-v8": "4.1.0",
"@vitest/ui": "4.1.0",
"chalk": "5.6.2",
"cross-env": "10.1.0",
"dpdm": "4.0.1",
@@ -77,7 +77,7 @@
"upath": "2.0.1",
"vite": "7.3.1",
"vite-plugin-dts": "4.5.4",
"vitest": "4.0.18"
"vitest": "4.1.0"
},
"license": "AGPL-3.0-only",
"author": {

View File

@@ -26,8 +26,8 @@
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -38,7 +38,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.1.0",
"webdriverio": "9.25.0"
},
"peerDependencies": {

View File

@@ -27,8 +27,8 @@
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -39,7 +39,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.1.0",
"webdriverio": "9.25.0"
},
"peerDependencies": {

View File

@@ -29,8 +29,8 @@
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.1.0",
"webdriverio": "9.25.0"
},
"peerDependencies": {

View File

@@ -29,8 +29,8 @@
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.1.0",
"webdriverio": "9.25.0"
},
"peerDependencies": {
@@ -71,6 +71,6 @@
},
"dependencies": {
"@ckeditor/ckeditor5-icons": "47.4.0",
"mathlive": "0.108.3"
"mathlive": "0.109.0"
}
}

View File

@@ -29,8 +29,8 @@
"@ckeditor/ckeditor5-package-tools": "5.0.1",
"@typescript-eslint/eslint-plugin": "8.57.0",
"@typescript-eslint/parser": "8.57.0",
"@vitest/browser": "4.0.18",
"@vitest/coverage-istanbul": "4.0.18",
"@vitest/browser": "4.1.0",
"@vitest/coverage-istanbul": "4.1.0",
"ckeditor5": "47.4.0",
"eslint": "10.0.3",
"eslint-config-ckeditor5": ">=9.1.0",
@@ -41,7 +41,7 @@
"ts-node": "10.9.2",
"typescript": "5.9.3",
"vite-plugin-svgo": "2.0.0",
"vitest": "4.0.18",
"vitest": "4.1.0",
"webdriverio": "9.25.0"
},
"peerDependencies": {

944
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff