diff --git a/.gitignore b/.gitignore index 1d82de1ef..4dd7bde62 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,4 @@ upload # docs site/ apps/*/coverage +scripts/translation/.language*.json \ No newline at end of file diff --git a/apps/client/src/services/content_renderer.ts b/apps/client/src/services/content_renderer.ts index aef64291f..7137efae7 100644 --- a/apps/client/src/services/content_renderer.ts +++ b/apps/client/src/services/content_renderer.ts @@ -194,7 +194,7 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: if (type === "pdf") { const $pdfPreview = $(''); - $pdfPreview.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open`)); + $pdfPreview.attr("src", openService.getUrlForDownload(`pdfjs/web/viewer.html?file=../../api/${entityType}/${entityId}/open`)); $content.append($pdfPreview); } else if (type === "audio") { @@ -218,14 +218,14 @@ function renderFile(entity: FNote | FAttachment, type: string, $renderedContent: // in attachment list const $downloadButton = $(` `); const $openButton = $(` `); diff --git a/apps/client/src/translations/cn/translation.json b/apps/client/src/translations/cn/translation.json index 90f785272..60487553c 100644 --- a/apps/client/src/translations/cn/translation.json +++ b/apps/client/src/translations/cn/translation.json @@ -1596,7 +1596,9 @@ "toggle-sidebar": "切换侧边栏", "dropping-not-allowed": "不允许移动笔记到此处。", "shared-indicator-tooltip": "此笔记已公开分享", - "shared-indicator-tooltip-with-url": "此笔记已公开分享至:{{- url}}" + "shared-indicator-tooltip-with-url": "此笔记已公开分享至:{{- url}}", + "clone-indicator-tooltip": "此笔记有 {{- count}} 个父级: {{- parents}}", + "clone-indicator-tooltip-single": "此笔记已克隆(1 个额外的父级:{{- parent}})" }, "title_bar_buttons": { "window-on-top": "保持此窗口置顶" @@ -2194,7 +2196,14 @@ "execute_sql_description": "这是一篇 SQL 笔记。点击即可执行 SQL 查询。", "shared_copy_to_clipboard": "复制链接到剪贴板", "shared_open_in_browser": "在浏览器中打开链接", - "shared_unshare": "取消共享" + "shared_unshare": "取消共享", + "save_status_saved": "已保存", + "save_status_saving": "保存中...", + "save_status_unsaved": "未保存", + "save_status_error": "保存失败", + "save_status_unsaved_tooltip": "还有一些更改尚未保存。它们将稍后自动保存。", + "save_status_error_tooltip": "保存笔记时出错。如果可以,请尝试将笔记内容复制到其他位置并重新加载应用程序。", + "save_status_saving_tooltip": "更改正在保存。" }, "status_bar": { "language_title": "更改内容语言", diff --git a/apps/client/src/translations/ja/translation.json b/apps/client/src/translations/ja/translation.json index 9cde96ee8..7fb1bd58b 100644 --- a/apps/client/src/translations/ja/translation.json +++ b/apps/client/src/translations/ja/translation.json @@ -2196,7 +2196,14 @@ "execute_sql_description": "このノートは SQL ノートです。クリックすると SQL クエリが実行されます。", "shared_copy_to_clipboard": "リンクをクリップボードにコピー", "shared_open_in_browser": "ブラウザでリンクを開く", - "shared_unshare": "共有を削除" + "shared_unshare": "共有を削除", + "save_status_saved": "保存されました", + "save_status_saving": "保存中...", + "save_status_unsaved": "未保存", + "save_status_error": "保存に失敗しました", + "save_status_saving_tooltip": "変更を保存しています。", + "save_status_unsaved_tooltip": "未保存の変更があります。すぐに自動的に保存されます。", + "save_status_error_tooltip": "ノートの保存中にエラーが発生しました。可能であれば、ノートの内容を別の場所にコピーして、アプリケーションを再読み込みしてください。" }, "status_bar": { "language_title": "コンテンツの言語を変更", diff --git a/apps/client/src/translations/tw/translation.json b/apps/client/src/translations/tw/translation.json index e3766db85..c51d511ed 100644 --- a/apps/client/src/translations/tw/translation.json +++ b/apps/client/src/translations/tw/translation.json @@ -2200,7 +2200,14 @@ "read_only_temporarily_disabled_description": "此筆記目前可編輯,但通常為唯讀狀態。當您切換至其他筆記時,本筆記將立即恢復為唯讀模式。\n\n點擊此處重新啟用唯讀模式。", "clipped_note_description": "本筆記原始來源為 {{url}}。\n\n點擊此處前往原網頁。", "execute_script_description": "此筆記為腳本筆記。點擊以執行腳本。", - "execute_sql_description": "此筆記為 SQL 筆記。點擊以執行 SQL 查詢。" + "execute_sql_description": "此筆記為 SQL 筆記。點擊以執行 SQL 查詢。", + "save_status_saved": "已儲存", + "save_status_saving": "正在儲存…", + "save_status_unsaved": "未儲存", + "save_status_error": "儲存失敗", + "save_status_saving_tooltip": "正在儲存更動。", + "save_status_unsaved_tooltip": "仍有更動尚未儲存。它們將在稍後自動儲存。", + "save_status_error_tooltip": "在儲存筆記時發生錯誤。如果可以,請嘗試將筆記內容複製至他處並重新載入應用程式。" }, "breadcrumb": { "hoisted_badge": "聚焦", @@ -2246,6 +2253,6 @@ "pages_one": "共 {{count}} 頁", "pages_other": "", "pages_alt": "第 {{pageNumber}} 頁", - "pages_loading": "載入中…" + "pages_loading": "正在載入…" } } diff --git a/apps/client/src/types-pdfjs.d.ts b/apps/client/src/types-pdfjs.d.ts index 12756778e..3a4b3f16d 100644 --- a/apps/client/src/types-pdfjs.d.ts +++ b/apps/client/src/types-pdfjs.d.ts @@ -45,6 +45,10 @@ interface WithContext { interface PdfDocumentModifiedMessage extends WithContext { type: "pdfjs-viewer-document-modified"; +} + +interface PdfDocumentBlobResultMessage extends WithContext { + type: "pdfjs-viewer-blob"; data: Uint8Array; } @@ -113,4 +117,5 @@ type PdfMessageEvent = MessageEvent< | PdfViewerThumbnailMessage | PdfViewerAttachmentsMessage | PdfViewerLayersMessage + | PdfDocumentBlobResultMessage >; diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 493640d25..9d1721a97 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -1,27 +1,29 @@ -import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; -import { ViewModeProps } from "../interface"; -import Calendar from "./calendar"; -import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import "./index.css"; -import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; -import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; + import { Calendar as FullCalendar } from "@fullcalendar/core"; -import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; -import dialog from "../../../services/dialog"; -import { t } from "../../../services/i18n"; -import { buildEvents, buildEventsForCalendar } from "./event_builder"; -import { changeEvent, newEvent } from "./api"; -import froca from "../../../services/froca"; -import date_notes from "../../../services/date_notes"; -import appContext from "../../../components/app_context"; +import { DateSelectArg, EventChangeArg, EventMountArg, EventSourceFuncArg, LocaleInput, PluginDef } from "@fullcalendar/core/index.js"; import { DateClickArg } from "@fullcalendar/interaction"; -import FNote from "../../../entities/fnote"; -import Button, { ButtonGroup } from "../../react/Button"; -import ActionButton from "../../react/ActionButton"; +import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; import { RefObject } from "preact"; -import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar"; -import { openCalendarContextMenu } from "./context_menu"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; + +import appContext from "../../../components/app_context"; +import FNote from "../../../entities/fnote"; +import date_notes from "../../../services/date_notes"; +import dialog from "../../../services/dialog"; +import froca from "../../../services/froca"; +import { t } from "../../../services/i18n"; import { isMobile } from "../../../services/utils"; +import ActionButton from "../../react/ActionButton"; +import Button, { ButtonGroup } from "../../react/Button"; +import { useNoteLabel, useNoteLabelBoolean, useResizeObserver, useSpacedUpdate, useTriliumEvent, useTriliumOption, useTriliumOptionInt } from "../../react/hooks"; +import TouchBar, { TouchBarButton, TouchBarLabel, TouchBarSegmentedControl, TouchBarSpacer } from "../../react/TouchBar"; +import { ViewModeProps } from "../interface"; +import { changeEvent, newEvent } from "./api"; +import Calendar from "./calendar"; +import { openCalendarContextMenu } from "./context_menu"; +import { buildEvents, buildEventsForCalendar } from "./event_builder"; +import { parseStartEndDateFromEvent, parseStartEndTimeFromEvent } from "./utils"; interface CalendarViewData { @@ -59,7 +61,7 @@ const CALENDAR_VIEWS = [ previousText: t("calendar.month_previous"), nextText: t("calendar.month_next") } -] +]; const SUPPORTED_CALENDAR_VIEW_TYPE = CALENDAR_VIEWS.map(v => v.type); @@ -75,6 +77,7 @@ export const LOCALE_MAPPINGS: Record Promise<{ de ru: () => import("@fullcalendar/core/locales/ru"), ja: () => import("@fullcalendar/core/locales/ja"), pt: () => import("@fullcalendar/core/locales/pt"), + pl: () => import("@fullcalendar/core/locales/pl"), "pt_br": () => import("@fullcalendar/core/locales/pt-br"), uk: () => import("@fullcalendar/core/locales/uk"), en: null, @@ -102,9 +105,9 @@ export default function CalendarView({ note, noteIds }: ViewModeProps { if (!isCalendarRoot) { return async () => await buildEvents(noteIds); - } else { - return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e); - } + } + return async (e: EventSourceFuncArg) => await buildEventsForCalendar(note, e); + }, [isCalendarRoot, noteIds]); const plugins = usePlugins(isEditable, isCalendarRoot); @@ -178,7 +181,7 @@ function CalendarHeader({ calendarRef }: { calendarRef: RefObject calendarRef.current?.next()} /> - ) + ); } function usePlugins(isEditable: boolean, isCalendarRoot: boolean) { @@ -293,7 +296,7 @@ function useEventDisplayCustomization(parentNote: FNote) { if (promotedAttributes) { let promotedAttributesHtml = ""; for (const [name, value] of promotedAttributes) { - promotedAttributesHtml = promotedAttributesHtml + /*html*/`\ + promotedAttributesHtml = `${promotedAttributesHtml /*html*/}\ `; diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.css b/apps/client/src/widgets/collections/legacy/ListOrGridView.css index c6b21c0b1..1bfa389e5 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.css +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.css @@ -142,4 +142,10 @@ border: 1px solid var(--main-border-color); background: var(--more-accented-background-color); } + +.note-list.grid-view .note-path { + margin-left: 0.5em; + vertical-align: middle; + opacity: 0.5; +} /* #endregion */ diff --git a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx index 8180e6d65..5b0d47d97 100644 --- a/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx +++ b/apps/client/src/widgets/collections/legacy/ListOrGridView.tsx @@ -7,7 +7,6 @@ import attribute_renderer from "../../../services/attribute_renderer"; import content_renderer from "../../../services/content_renderer"; import { t } from "../../../services/i18n"; import link from "../../../services/link"; -import tree from "../../../services/tree"; import { useImperativeSearchHighlighlighting, useNoteLabel, useNoteLabelBoolean } from "../../react/hooks"; import Icon from "../../react/Icon"; import NoteLink from "../../react/NoteLink"; @@ -103,16 +102,7 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan } function GridNoteCard({ note, parentNote, highlightedTokens }: { note: FNote, parentNote: FNote, highlightedTokens: string[] | null | undefined }) { - const titleRef = useRef(null); - const [ noteTitle, setNoteTitle ] = useState(); const notePath = getNotePath(parentNote, note); - const highlightSearch = useImperativeSearchHighlighlighting(highlightedTokens); - - useEffect(() => { - tree.getNoteTitle(note.noteId, parentNote.noteId).then(setNoteTitle); - }, [ note ]); - - useEffect(() => highlightSearch(titleRef.current), [ noteTitle, highlightedTokens ]); return (
- {noteTitle} +
Promise | Blob | undefined, + onContentChange: (newBlob: FBlob) => void, + dataSaved?: (savedData: Blob) => void, + updateInterval?: number; + /** If set to true, then the blob is replaced directly without saving a revision before. */ + replaceWithoutRevision?: boolean; +}) { + const parentComponent = useContext(ParentComponent); + const blob = useNoteBlob(note, parentComponent?.componentId); + + const callback = useMemo(() => { + return async () => { + const data = await getData(); + + // for read only notes + if (data === undefined || note.type !== noteType) return; + + protected_session_holder.touchProtectedSessionIfNecessary(note); + await server.upload(`notes/${note.noteId}/file?replace=${replaceWithoutRevision ? "1" : "0"}`, new File([ data ], note.title, { type: note.mime }), parentComponent?.componentId); + dataSaved?.(data); + }; + }, [ note, getData, dataSaved, noteType, parentComponent, replaceWithoutRevision ]); + const stateCallback = useCallback((state) => { + noteContext?.setContextData("saveState", { + state + }); + }, [ noteContext ]); + const spacedUpdate = useSpacedUpdate(callback, updateInterval, stateCallback); + + // React to note/blob changes. + useEffect(() => { + if (!blob) return; + spacedUpdate.allowUpdateWithoutChange(() => onContentChange(blob)); + }, [ blob ]); + + // React to update interval changes. + useEffect(() => { + if (!updateInterval) return; + spacedUpdate.setUpdateInterval(updateInterval); + }, [ updateInterval ]); + + // Save if needed upon switching tabs. + useTriliumEvent("beforeNoteSwitch", async ({ noteContext: eventNoteContext }) => { + if (eventNoteContext.ntxId !== noteContext?.ntxId) return; + await spacedUpdate.updateNowIfNecessary(); + }); + + // Save if needed upon tab closing. + useTriliumEvent("beforeNoteContextRemove", async ({ ntxIds }) => { + if (!noteContext?.ntxId || !ntxIds.includes(noteContext.ntxId)) return; + await spacedUpdate.updateNowIfNecessary(); + }); + + // Save if needed upon window/browser closing. + useEffect(() => { + const listener = () => spacedUpdate.isAllSavedAndTriggerUpdate(); + appContext.addBeforeUnloadListener(listener); + return () => appContext.removeBeforeUnloadListener(listener); + }, []); + + return spacedUpdate; +} + export function useNoteSavedData(noteId: string | undefined) { return useSyncExternalStore( (cb) => noteId ? noteSavedDataStore.subscribe(noteId, cb) : () => {}, diff --git a/apps/client/src/widgets/type_widgets/MindMap.tsx b/apps/client/src/widgets/type_widgets/MindMap.tsx index 3b5631338..8c6b36eb7 100644 --- a/apps/client/src/widgets/type_widgets/MindMap.tsx +++ b/apps/client/src/widgets/type_widgets/MindMap.tsx @@ -37,6 +37,7 @@ const LOCALE_MAPPINGS: Record it: "it", ja: "ja", pt: "pt", + pl: null, pt_br: "pt", ro: "ro", ru: "ru", diff --git a/apps/client/src/widgets/type_widgets/canvas/i18n.ts b/apps/client/src/widgets/type_widgets/canvas/i18n.ts index 47324abfc..ba044032b 100644 --- a/apps/client/src/widgets/type_widgets/canvas/i18n.ts +++ b/apps/client/src/widgets/type_widgets/canvas/i18n.ts @@ -13,6 +13,7 @@ export const LANGUAGE_MAPPINGS: Record(null); const historyConfig = useViewModeConfig(note, "pdfHistory"); + const spacedUpdate = useBlobEditorSpacedUpdate({ + note, + noteType: "file", + noteContext, + getData() { + if (!iframeRef.current?.contentWindow) return undefined; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout while waiting for blob response")); + }, 10_000); + + const onMessageReceived = (event: PdfMessageEvent) => { + if (event.data.type !== "pdfjs-viewer-blob") return; + if (event.data.noteId !== note.noteId || event.data.ntxId !== noteContext.ntxId) return; + const blob = new Blob([event.data.data as Uint8Array], { type: note.mime }); + + clearTimeout(timeout); + window.removeEventListener("message", onMessageReceived); + resolve(blob); + }; + + window.addEventListener("message", onMessageReceived); + iframeRef.current?.contentWindow?.postMessage({ + type: "trilium-request-blob", + }, window.location.origin); + }); + }, + onContentChange() { + if (iframeRef.current?.contentWindow) { + iframeRef.current.contentWindow.location.reload(); + } + }, + replaceWithoutRevision: true + }); + useEffect(() => { function handleMessage(event: PdfMessageEvent) { if (event.data?.type === "pdfjs-viewer-document-modified") { - const blob = new Blob([event.data.data as Uint8Array], { type: note.mime }); if (event.data.noteId === note.noteId && event.data.ntxId === noteContext.ntxId) { - server.upload(`notes/${note.noteId}/file`, new File([blob], note.title, { type: note.mime }), componentId); + spacedUpdate.resetUpdateTimer(); + spacedUpdate.scheduleUpdate(); } } @@ -138,13 +173,6 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: { }; }, [ note, historyConfig, componentId, blob, noteContext ]); - // Refresh when blob changes. - useEffect(() => { - if (iframeRef.current?.contentWindow) { - iframeRef.current.contentWindow.location.reload(); - } - }, [ blob ]); - useTriliumEvent("customDownload", ({ ntxId }) => { if (ntxId !== noteContext.ntxId) return; iframeRef.current?.contentWindow?.postMessage({ @@ -171,6 +199,7 @@ export default function PdfPreview({ note, blob, componentId, noteContext }: { }); } }} + editable /> ); } diff --git a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx index 82bc4a2b9..7e6870dd4 100644 --- a/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx +++ b/apps/client/src/widgets/type_widgets/file/PdfViewer.tsx @@ -15,12 +15,16 @@ interface PdfViewerProps extends Pick, "tabInd /** Note: URLs are relative to /pdfjs/web. */ pdfUrl: string; onLoad?(): void; + /** + * If set, enables editable mode which includes persistence of user settings, annotations as well as specific features such as sending table of contents data for the sidebar. + */ + editable?: boolean; } /** * Reusable component displaying a PDF. The PDF needs to be provided via a URL. */ -export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad }: PdfViewerProps) { +export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad, editable }: PdfViewerProps) { const iframeRef = useSyncedRef(externalIframeRef, null); const [ locale ] = useTriliumOption("locale"); const [ newLayout ] = useTriliumOptionBool("newLayout"); @@ -30,7 +34,7 @@ export default function PdfViewer({ iframeRef: externalIframeRef, pdfUrl, onLoad