diff --git a/apps/client/package.json b/apps/client/package.json index 8f0026fbf9..b04b137fa4 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -52,6 +52,7 @@ "dompurify": "3.4.0", "draggabilly": "3.0.0", "force-graph": "1.51.2", + "htmldiff-js": "1.0.5", "i18next": "26.0.4", "i18next-http-backend": "3.0.4", "jquery": "4.0.0", diff --git a/apps/client/src/components/app_context.ts b/apps/client/src/components/app_context.ts index 27fc2e1396..ba213a4edf 100644 --- a/apps/client/src/components/app_context.ts +++ b/apps/client/src/components/app_context.ts @@ -281,6 +281,7 @@ export type CommandMappings = { backInNoteHistory: CommandData; forwardInNoteHistory: CommandData; forceSaveRevision: CommandData; + saveNamedRevision: CommandData; scrollToActiveNote: CommandData; quickSearch: CommandData; collapseTree: CommandData; diff --git a/apps/client/src/components/entrypoints.ts b/apps/client/src/components/entrypoints.ts index 8fc4e1b3d5..781790e156 100644 --- a/apps/client/src/components/entrypoints.ts +++ b/apps/client/src/components/entrypoints.ts @@ -1,6 +1,7 @@ import { CreateChildrenResponse, SqlExecuteResponse } from "@triliumnext/commons"; import bundleService from "../services/bundle.js"; +import dialog from "../services/dialog.js"; import dateNoteService from "../services/date_notes.js"; import froca from "../services/froca.js"; import { t } from "../services/i18n.js"; @@ -216,4 +217,21 @@ export default class Entrypoints extends Component { toastService.showMessage(t("entrypoints.note-revision-created")); } + + async saveNamedRevisionCommand() { + const noteId = appContext.tabManager.getActiveContextNoteId(); + if (!noteId) return; + + const name = await dialog.prompt({ + title: t("entrypoints.save-named-revision-title"), + message: t("entrypoints.save-named-revision-message"), + defaultValue: "" + }); + + // null means the user cancelled + if (name === null) return; + + await server.post(`notes/${noteId}/revision`, { description: name || undefined }); + toastService.showMessage(t("entrypoints.note-revision-created")); + } } diff --git a/apps/client/src/stylesheets/style.css b/apps/client/src/stylesheets/style.css index c1672f50c4..1fdfe9aeea 100644 --- a/apps/client/src/stylesheets/style.css +++ b/apps/client/src/stylesheets/style.css @@ -835,6 +835,7 @@ table.promoted-attributes-in-tooltip th { text-align: start; color: var(--main-text-color) !important; max-width: 500px; + white-space: pre-line; box-shadow: 10px 10px 93px -25px #aaaaaa; } @@ -960,15 +961,19 @@ table.promoted-attributes-in-tooltip th { background-color: var(--active-item-background-color); } -.help-button { +.help-button, +.custom-title-bar-button { float: inline-end; background: none; font-weight: 900; - color: orange; border: 0; cursor: pointer; } +.help-button { + color: orange; +} + .multiplicity { font-size: 1.3em; } @@ -1147,11 +1152,90 @@ a.external:not(.no-arrow):after, a[href^="http://"]:not(.no-arrow):after, a[href padding: 0.5rem 1rem 0.5rem 1rem !important; /* make modal header padding slightly smaller */ } -.modal-header .help-button { +.modal-header .help-button, +.modal-header .custom-title-bar-button { padding: 6px; margin: 0 12px; } +.modal-content-with-sidebar { + flex-direction: row !important; +} + +.modal-content-with-sidebar > .modal-sidebar { + display: flex; + flex-direction: column; + border-right: 1px solid var(--main-border-color); + flex-shrink: 0; + min-height: 0; +} + +.modal-content-with-sidebar .modal-sidebar-header { + padding: 0.75rem 1rem; + flex-shrink: 0; + text-align: center; + border-bottom: 1px solid var(--main-border-color); +} + +.modal-content-with-sidebar .modal-sidebar-header h5 { + margin: 0; + font-size: 1em; +} + +.modal-content-with-sidebar > .modal-main > .modal-header > .modal-title { + visibility: hidden; + flex-grow: 1; + width: 0; + padding: 0; + margin: 0; + overflow: hidden; +} + +.modal-content-with-sidebar > .modal-main > .modal-header { + flex-wrap: nowrap; +} + +.modal-content-with-sidebar > .modal-main { + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.modal-content-with-sidebar > .modal-main > .modal-body { + overflow: auto; + flex-grow: 1; + min-height: 0; +} + +body.mobile .modal-content-with-sidebar { + flex-direction: column !important; +} + +body.mobile .modal-content-with-sidebar > .modal-sidebar { + border-right: none; + border-bottom: 1px solid var(--main-border-color); + height: 30vh; + flex-shrink: 0; + overflow: hidden; + order: 1; +} + +body.mobile .modal-content-with-sidebar > .modal-main { + order: 0; +} + +body.mobile .modal-content-with-sidebar .modal-sidebar-header { + display: none; +} + +body.mobile .modal-content-with-sidebar > .modal-main > .modal-header > .modal-title { + visibility: visible; + width: auto; +} + .ck-mentions .ck-button { font-size: var(--detail-font-size) !important; padding: 5px; diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 79cf207780..2baa838fb1 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -287,6 +287,7 @@ "confirm_delete_all": "Do you want to delete all revisions of this note?", "no_revisions": "No revisions for this note yet...", "restore_button": "Restore", + "highlight_changes": "Highlight changes", "diff_on": "Show diff", "diff_off": "Show content", "diff_on_hint": "Click to show note source diff", @@ -300,11 +301,39 @@ "revision_deleted": "Note revision has been deleted.", "snapshot_interval": "Note Revision Snapshot Interval: {{seconds}}s.", "maximum_revisions": "Note Revision Snapshot Limit: {{number}}.", + "save_revision_now": "Save a revision now", + "save_named_revision": "Save named revision...", + "snapshot_header": "Note revision snapshot", + "snapshot_interval_value": "Interval: {{seconds}}s", + "snapshot_limit_value": "Limit: {{number}}", "settings": "Note Revision Settings", + "menu_tooltip": "Revision options", "download_button": "Download", "mime": "MIME: ", "file_size": "File size:", - "preview_not_available": "Preview isn't available for this note type." + "preview_not_available": "Preview isn't available for this note type.", + "save_revision": "Save revision", + "save_revision_tooltip": "Manually save a snapshot of the current note", + "description_placeholder": "Name this revision", + "revision_saved": "Note revision has been saved.", + "edit_description": "Edit name", + "description_updated": "Revision name has been updated.", + "source_auto": "Auto-save", + "source_manual": "Manual save", + "source_etapi": "ETAPI", + "source_llm": "LLM", + "source_restore": "Restore", + "source_unknown": "Snapshot", + "date_today": "Today", + "date_yesterday": "Yesterday", + "date_this_week": "This week", + "date_this_month": "This month", + "source_description_auto": "Automatically saved by the system at regular intervals", + "source_description_manual": "Manually saved by the user", + "source_description_etapi": "Created via the External Trilium API", + "source_description_llm": "Created by the AI assistant", + "source_description_restore": "Saved before restoring a previous revision", + "source_description_unknown": "Source not available" }, "sort_child_notes": { "sort_children_by": "Sort children by...", @@ -718,6 +747,7 @@ "print_note": "Print note", "view_revisions": "Note revisions...", "save_revision": "Save revision", + "save_named_revision": "Save named revision...", "advanced": "Advanced", "convert_into_attachment_failed": "Converting note '{{title}}' failed.", "convert_into_attachment_successful": "Note '{{title}}' has been converted to attachment.", @@ -1897,6 +1927,8 @@ }, "entrypoints": { "note-revision-created": "Note revision has been created.", + "save-named-revision-title": "Save named revision", + "save-named-revision-message": "Enter a name for this revision (leave empty for no name):", "note-executed": "Note executed.", "sql-error": "Error occurred while executing SQL query: {{message}}" }, @@ -2507,5 +2539,9 @@ "move_note": "Move note", "clone_note": "Clone note" } + }, + "common": { + "save": "Save", + "cancel": "Cancel" } } diff --git a/apps/client/src/types-lib.d.ts b/apps/client/src/types-lib.d.ts index 8d3c9296d1..c559dc6a55 100644 --- a/apps/client/src/types-lib.d.ts +++ b/apps/client/src/types-lib.d.ts @@ -1,3 +1,10 @@ +declare module "htmldiff-js" { + const HtmlDiff: { + execute(oldHtml: string, newHtml: string): string; + }; + export default HtmlDiff; +} + // TODO: Use real @types/ but that one generates a lot of errors. declare module "draggabilly" { type DraggabillyEventData = {}; diff --git a/apps/client/src/widgets/dialogs/revisions.css b/apps/client/src/widgets/dialogs/revisions.css index 7d02c9cdbb..0985839c04 100644 --- a/apps/client/src/widgets/dialogs/revisions.css +++ b/apps/client/src/widgets/dialogs/revisions.css @@ -3,74 +3,209 @@ body.mobile .revisions-dialog { height: 95vh; } - .modal-header { - display: flex; - flex-wrap: wrap; - gap: 0.25em; - font-size: 0.9em; - } - - .modal-title { - flex-grow: 1; - width: 100%; - } - - .modal-body { - height: fit-content !important; - flex-direction: column; - padding: 0; - } - .modal-footer { font-size: 0.9em; } - .revision-list { - height: fit-content !important; - max-height: 20vh; - border-bottom: 1px solid var(--main-border-color) !important; - padding: 0 1em; - flex-shrink: 0; - } - - .modal-body > .revision-content-wrapper { - flex-grow: 1; - max-width: unset !important; - height: 100%; - margin: 0; - display: block !important; - } - - .modal-body > .revision-content-wrapper > div:first-of-type { - flex-direction: column; - } - .revision-title { font-size: 1rem; } - .revision-title-buttons { - text-align: center; - display: flex; - gap: 0.25em; + .revision-toolbar-actions { flex-wrap: wrap; } .revision-content { padding: 0.5em; - height: fit-content; + } +} + +body.desktop .revisions-dialog { + .revision-list { + width: 300px; + } + + .modal-content-with-sidebar { + height: 80vh; } } .revisions-dialog { - .revision-title-buttons { + .modal-body { + padding: 0; + display: flex; + flex-direction: column; + } + + .modal-sidebar { + background-color: var(--card-background-color); + } + + .modal-sidebar .dropdown-menu.static { + background-color: transparent !important; + border-radius: 0 !important; + } + + .revision-toolbar { flex-shrink: 0; + border-bottom: 1px solid var(--main-border-color); + padding: 8px 20px; + } + + .revision-title { + font-size: 1.2em; + margin: 8px 0; + } + + .revision-toolbar-actions { + display: flex; + align-items: center; + gap: 4px; + } + + .revision-menu-header { + font-weight: bold; + font-size: 0.85em; + text-transform: uppercase; + opacity: 0.6; + } + + .revision-content-wrapper { + flex-grow: 1; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + } + + .revision-content { + flex-grow: 1; + min-height: 0; + overflow: auto; + padding: 20px; + } + + .no-items { + padding-block: 3em; } .revision-list { + flex: 1 1 0; + min-height: 0; + overflow: auto; + + .dropdown-item { + min-height: 2.5em; + + >div { + padding-left: 0.25em; + min-width: 0; + } + } + } + + .revision-item-description { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 0.85em; + opacity: 0.7; + } + + .revision-group-header { + font-size: 0.75em; + font-weight: bold; + text-transform: uppercase; + opacity: 0.5; + padding: 6px 12px 2px; + } + + .revision-item-meta { + font-size: 0.85em; + opacity: 0.7; + } + + + .revision-description-icon { + opacity: 0.5; flex-shrink: 0; } + .revision-description-editor { + display: flex; + gap: 5px; + align-items: center; + margin: 3px 0; + + input { + flex-grow: 1; + } + } + + .revision-description-display { + display: flex; + align-items: center; + margin: 3px 0; + gap: 5px; + min-height: 24px; + } + + .revision-description-text { + font-size: 0.9em; + + &.empty { + opacity: 0.5; + font-style: italic; + } + } + + .revision-diff-code { + font-family: var(--font-family-monospace, monospace); + font-size: 0.9rem; + white-space: pre-wrap; + word-break: break-all; + max-width: 100%; + padding: 0; + } + + /* HTML diff styles (htmldiff-js) */ + .revision-diff-content { + ins { + text-decoration: none; + + &.diffins, + &.diffmod { + background-color: color-mix(in srgb, var(--bs-success) 25%, transparent); + } + } + + del { + text-decoration: line-through; + + &.diffdel, + &.diffmod { + background-color: color-mix(in srgb, var(--bs-danger) 25%, transparent); + } + } + + /* Image diff styles */ + ins img, + del img { + border: 3px solid; + border-radius: 4px; + position: relative; + } + + del img { + border-color: var(--bs-danger); + opacity: 0.6; + } + + ins img { + border-color: var(--bs-success); + } + } + .revision-content.type-file { display: flex; min-width: 0; diff --git a/apps/client/src/widgets/dialogs/revisions.tsx b/apps/client/src/widgets/dialogs/revisions.tsx index 66ce763a3d..787264b1ac 100644 --- a/apps/client/src/widgets/dialogs/revisions.tsx +++ b/apps/client/src/widgets/dialogs/revisions.tsx @@ -1,8 +1,10 @@ import "./revisions.css"; -import type { RevisionItem, RevisionPojo } from "@triliumnext/commons"; +import { dayjs, type RevisionItem, type RevisionPojo } from "@triliumnext/commons"; import clsx from "clsx"; import { diffWords } from "diff"; +import HtmlDiff from "htmldiff-js"; +import { Fragment } from "preact"; import type { CSSProperties } from "preact/compat"; import { Dispatch, StateUpdater, useEffect, useRef, useState } from "preact/hooks"; @@ -20,11 +22,13 @@ import toast from "../../services/toast"; import utils from "../../services/utils"; import ActionButton from "../react/ActionButton"; import Button from "../react/Button"; -import FormList, { FormListItem } from "../react/FormList"; +import Dropdown from "../react/Dropdown"; +import FormList, { FormDropdownDivider, FormListItem } from "../react/FormList"; import FormToggle from "../react/FormToggle"; import { useTriliumEvent } from "../react/hooks"; import Modal from "../react/Modal"; -import { RawHtmlBlock } from "../react/RawHtml"; +import NoItems from "../react/NoItems"; +import { RawHtmlBlock, SanitizedHtml } from "../react/RawHtml"; import PdfViewer from "../type_widgets/file/PdfViewer"; export default function RevisionsDialog() { @@ -33,7 +37,7 @@ export default function RevisionsDialog() { const [ revisions, setRevisions ] = useState(); const [ currentRevision, setCurrentRevision ] = useState(); const [ shown, setShown ] = useState(false); - const [ showDiff, setShowDiff ] = useState(false); + const [ showDiff, setShowDiff ] = useState(true); const [ refreshCounter, setRefreshCounter ] = useState(0); useTriliumEvent("showRevisions", async ({ noteId }) => { @@ -54,114 +58,390 @@ export default function RevisionsDialog() { } }, [ note, refreshCounter ]); + const revisionsLoaded = revisions !== undefined; + const hasRevisions = !!revisions?.length; + if (revisions?.length && !currentRevision) { setCurrentRevision(revisions[0]); } + const onHidden = () => { + setShown(false); + setShowDiff(true); + setNote(undefined); + setCurrentRevision(undefined); + setRevisions(undefined); + }; + + if (revisionsLoaded && !hasRevisions) { + return ( + { + setRefreshCounter(c => c + 1); + setCurrentRevision(undefined); + }} + onAllDeleted={() => { + setRevisions([]); + setCurrentRevision(undefined); + }} + hasRevisions={false} + /> + )} + onHidden={onHidden} + show={shown} + > + + + ); + } + return ( - {["text", "code", "mermaid"].includes(currentRevision?.type ?? "") && ( - setShowDiff(newValue)} - switchOnName={t("revisions.diff_on")} - switchOffName={t("revisions.diff_off")} - switchOnTooltip={t("revisions.diff_on_hint")} - switchOffTooltip={t("revisions.diff_off_hint")} - /> - )} -   - + )} + + {titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => ( + - )} - - {titleBarButtons?.filter((b) => b !== null).map((titleBarButton) => ( -