mirror of
https://github.com/zadam/trilium.git
synced 2026-02-19 12:14:23 +01:00
UI: improved pager (#8730)
This commit is contained in:
commit
6aaf277b45
@ -145,6 +145,10 @@ button.tn-low-profile:hover {
|
||||
font-size: calc(var(--icon-button-size) * var(--icon-button-icon-ratio));
|
||||
}
|
||||
|
||||
:root .icon-action.disabled::before {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
:root .icon-action:not(.global-menu-button):hover,
|
||||
:root .icon-action:not(.global-menu-button).show,
|
||||
:root .tn-tool-button:hover,
|
||||
|
||||
@ -2190,8 +2190,9 @@
|
||||
"percentage": "%"
|
||||
},
|
||||
"pagination": {
|
||||
"page_title": "Page of {{startIndex}} - {{endIndex}}",
|
||||
"total_notes": "{{count}} notes"
|
||||
"total_notes": "{{count}} notes",
|
||||
"prev_page": "Previous page",
|
||||
"next_page": "Next page"
|
||||
},
|
||||
"collections": {
|
||||
"rendering_error": "Unable to show content due to an error."
|
||||
|
||||
@ -23,14 +23,3 @@ body.prefers-centered-content .note-list-widget:not(.full-height) {
|
||||
.note-list-widget video {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* #region Pagination */
|
||||
.note-list-pager {
|
||||
font-size: 1rem;
|
||||
|
||||
span.current-page {
|
||||
text-decoration: underline;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
/* #endregion */
|
||||
|
||||
88
apps/client/src/widgets/collections/Pagination.css
Normal file
88
apps/client/src/widgets/collections/Pagination.css
Normal file
@ -0,0 +1,88 @@
|
||||
:where(.note-list-pager) {
|
||||
--note-list-pager-page-button-width: 40px;
|
||||
--note-list-pager-page-button-gap: 3px;
|
||||
--note-list-pager-ellipsis-width: 20px;
|
||||
--note-list-pager-justify-content: flex-end;
|
||||
|
||||
--note-list-pager-current-page-button-background-color: var(--button-group-active-button-background);
|
||||
--note-list-pager-current-page-button-text-color: var(--button-group-active-button-text-color);
|
||||
}
|
||||
|
||||
.note-list-pager-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
container: note-list-pager / inline-size;
|
||||
}
|
||||
|
||||
.note-list-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: .8rem;
|
||||
align-self: var(--note-list-pager-justify-content);
|
||||
|
||||
.note-list-pager-nav-button {
|
||||
--icon-button-icon-ratio: .75;
|
||||
}
|
||||
|
||||
.note-list-pager-page-button-container {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-around;
|
||||
gap: var(--note-list-pager-page-button-gap);
|
||||
|
||||
&.note-list-pager-ellipsis-present {
|
||||
/* Prevent the prev/next buttons from shifting when ellipses appear or disappear */
|
||||
--_gap-max-width: calc((var(--note-list-pager-page-button-count) + 2) * var(--note-list-pager-page-button-gap));
|
||||
|
||||
min-width: calc(var(--note-list-pager-page-button-count) * var(--note-list-pager-page-button-width)
|
||||
+ (var(--note-list-pager-ellipsis-width) * 2)
|
||||
+ var(--_gap-max-width));
|
||||
}
|
||||
|
||||
.note-list-pager-page-button {
|
||||
min-width: var(--note-list-pager-page-button-width);
|
||||
padding-inline: 0;
|
||||
padding-block: 4px;
|
||||
|
||||
&.note-list-pager-page-button-current {
|
||||
background: var(--note-list-pager-current-page-button-background-color);
|
||||
color: var(--note-list-pager-current-page-button-text-color);
|
||||
font-weight: bold;
|
||||
opacity: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list-pager-ellipsis {
|
||||
display: inline-block;
|
||||
width: var(--note-list-pager-ellipsis-width);
|
||||
text-align: center;
|
||||
opacity: .5;
|
||||
}
|
||||
}
|
||||
|
||||
.note-list-pager-narrow-counter {
|
||||
display: none;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.note-list-pager-total-count {
|
||||
margin-inline-start: 8px;
|
||||
opacity: .5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container note-list-pager (max-width: 550px) {
|
||||
.note-list-pager-page-button-container,
|
||||
.note-list-pager-total-count {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.note-list-pager-narrow-counter {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,10 @@ import FNote from "../../entities/fnote";
|
||||
import froca from "../../services/froca";
|
||||
import { useNoteLabelInt } from "../react/hooks";
|
||||
import { t } from "../../services/i18n";
|
||||
import ActionButton from "../react/ActionButton";
|
||||
import Button from "../react/Button";
|
||||
import "./Pagination.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface PaginationContext {
|
||||
page: number;
|
||||
@ -17,46 +21,106 @@ interface PaginationContext {
|
||||
export function Pager({ page, pageSize, setPage, pageCount, totalNotes }: Omit<PaginationContext, "pageNotes">) {
|
||||
if (pageCount < 2) return;
|
||||
|
||||
let lastPrinted = false;
|
||||
let children: ComponentChildren[] = [];
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
if (pageCount < 20 || i <= 5 || pageCount - i <= 5 || Math.abs(page - i) <= 2) {
|
||||
lastPrinted = true;
|
||||
|
||||
const startIndex = (i - 1) * pageSize + 1;
|
||||
const endIndex = Math.min(totalNotes, i * pageSize);
|
||||
|
||||
if (i !== page) {
|
||||
children.push((
|
||||
<a
|
||||
href="javascript:"
|
||||
title={t("pagination.page_title", { startIndex, endIndex })}
|
||||
onClick={() => setPage(i)}
|
||||
>
|
||||
{i}
|
||||
</a>
|
||||
))
|
||||
} else {
|
||||
// Current page
|
||||
children.push(<span className="current-page">{i}</span>)
|
||||
}
|
||||
|
||||
children.push(<>{" "} {" "}</>);
|
||||
} else if (lastPrinted) {
|
||||
children.push(<>{"... "} {" "}</>);
|
||||
lastPrinted = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="note-list-pager">
|
||||
{children}
|
||||
<div className="note-list-pager-container">
|
||||
<div className="note-list-pager">
|
||||
<ActionButton
|
||||
icon="bx bx-chevron-left"
|
||||
className="note-list-pager-nav-button"
|
||||
disabled={(page === 1)}
|
||||
text={t("pagination.prev_page")}
|
||||
onClick={() => setPage(page - 1)}
|
||||
/>
|
||||
|
||||
<span className="note-list-pager-total-count">({t("pagination.total_notes", { count: totalNotes })})</span>
|
||||
<PageButtons page={page} setPage={setPage} pageCount={pageCount} />
|
||||
<div className="note-list-pager-narrow-counter">
|
||||
<strong>{page}</strong> / <strong>{pageCount}</strong>
|
||||
</div>
|
||||
|
||||
<ActionButton
|
||||
icon="bx bx-chevron-right"
|
||||
className="note-list-pager-nav-button"
|
||||
disabled={(page === pageCount)}
|
||||
text={t("pagination.next_page")}
|
||||
onClick={() => setPage(page + 1)}
|
||||
/>
|
||||
|
||||
<div className="note-list-pager-total-count">
|
||||
{t("pagination.total_notes", { count: totalNotes })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface PageButtonsProps {
|
||||
page: number;
|
||||
setPage: Dispatch<StateUpdater<number>>;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
function PageButtons(props: PageButtonsProps) {
|
||||
const maxButtonCount = 9;
|
||||
const maxLeftRightSegmentLength = 2;
|
||||
|
||||
// The left-side segment
|
||||
const leftLength = Math.min(props.pageCount, maxLeftRightSegmentLength);
|
||||
const leftStart = 1;
|
||||
|
||||
// The middle segment
|
||||
const middleMaxLength = maxButtonCount - maxLeftRightSegmentLength * 2;
|
||||
const middleLength = Math.min(props.pageCount - leftLength, middleMaxLength);
|
||||
let middleStart = props.page - Math.floor(middleLength / 2);
|
||||
middleStart = Math.max(middleStart, leftLength + 1);
|
||||
|
||||
// The right-side segment
|
||||
const rightLength = Math.min(props.pageCount - (middleLength + leftLength), maxLeftRightSegmentLength);
|
||||
const rightStart = props.pageCount - rightLength + 1;
|
||||
middleStart = Math.min(middleStart, rightStart - middleLength);
|
||||
|
||||
const totalButtonCount = leftLength + middleLength + rightLength;
|
||||
const hasLeadingEllipsis = (middleStart - leftLength > 1);
|
||||
const hasTrailingEllipsis = (rightStart - (middleStart + middleLength - 1) > 1);
|
||||
|
||||
return <div className={clsx("note-list-pager-page-button-container", {
|
||||
"note-list-pager-ellipsis-present": (totalButtonCount === maxButtonCount)
|
||||
})}
|
||||
style={{"--note-list-pager-page-button-count": totalButtonCount}}>
|
||||
{[
|
||||
...createSegment(leftStart, leftLength, props.page, props.setPage, false),
|
||||
...createSegment(middleStart, middleLength, props.page, props.setPage, hasLeadingEllipsis),
|
||||
...createSegment(rightStart, rightLength, props.page, props.setPage, hasTrailingEllipsis),
|
||||
]}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function createSegment(start: number, length: number, currentPage: number, setPage: Dispatch<StateUpdater<number>>, prependEllipsis: boolean): ComponentChildren[] {
|
||||
const children: ComponentChildren[] = [];
|
||||
|
||||
if (prependEllipsis) {
|
||||
children.push(<span className="note-list-pager-ellipsis">...</span>);
|
||||
}
|
||||
|
||||
for (let i = 0; i < length; i++) {
|
||||
const pageNum = start + i;
|
||||
const isCurrent = (pageNum === currentPage);
|
||||
children.push((
|
||||
<Button
|
||||
text={pageNum.toString()}
|
||||
kind="lowProfile"
|
||||
className={clsx(
|
||||
"note-list-pager-page-button",
|
||||
{"note-list-pager-page-button-current": isCurrent}
|
||||
)}
|
||||
disabled={isCurrent}
|
||||
onClick={() => setPage(pageNum)}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function usePagination(note: FNote, noteIds: string[]): PaginationContext {
|
||||
const [ page, setPage ] = useState(1);
|
||||
const [ pageNotes, setPageNotes ] = useState<FNote[]>();
|
||||
|
||||
@ -100,10 +100,6 @@
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.note-list-pager {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* #region List view */
|
||||
|
||||
@keyframes note-preview-show {
|
||||
|
||||
@ -57,7 +57,7 @@ export default function BulkActionsDialog() {
|
||||
className="bulk-actions-dialog"
|
||||
size="xl"
|
||||
title={t("bulk_actions.bulk_actions")}
|
||||
footer={<Button text={t("bulk_actions.execute_bulk_actions")} primary />}
|
||||
footer={<Button text={t("bulk_actions.execute_bulk_actions")} kind="primary" />}
|
||||
show={shown}
|
||||
onSubmit={async () => {
|
||||
await server.post("bulk-action/execute", {
|
||||
|
||||
@ -72,7 +72,7 @@ export default function DeleteNotesDialog() {
|
||||
footer={<>
|
||||
<Button text={t("delete_notes.cancel")}
|
||||
onClick={() => setShown(false)} />
|
||||
<Button text={t("delete_notes.ok")} primary
|
||||
<Button text={t("delete_notes.ok")} kind="primary"
|
||||
buttonRef={okButtonRef}
|
||||
onClick={() => {
|
||||
opts.callback?.({ proceed: true, deleteAllClones, eraseNotes });
|
||||
|
||||
@ -58,7 +58,7 @@ export default function ExportDialog() {
|
||||
setShown(false);
|
||||
}}
|
||||
onHidden={() => setShown(false)}
|
||||
footer={<Button className="export-button" text={t("export.export")} primary />}
|
||||
footer={<Button className="export-button" text={t("export.export")} kind="primary" />}
|
||||
show={shown}
|
||||
>
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ export default function ImportDialog() {
|
||||
setShown(false);
|
||||
setFiles(null);
|
||||
}}
|
||||
footer={<Button text={t("import.import")} primary disabled={!files} />}
|
||||
footer={<Button text={t("import.import")} kind="primary" disabled={!files} />}
|
||||
show={shown}
|
||||
>
|
||||
<FormGroup name="files" label={t("import.chooseImportFile")} description={
|
||||
|
||||
@ -69,7 +69,7 @@ export default function PromptDialog() {
|
||||
submitValue.current = null;
|
||||
opts.current = undefined;
|
||||
}}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" primary />}
|
||||
footer={<Button text={t("prompt.ok")} keyboardShortcut="Enter" kind="primary" />}
|
||||
show={shown}
|
||||
stackable
|
||||
>
|
||||
|
||||
@ -203,7 +203,7 @@ function RevisionPreview({noteContent, revisionItem, showDiff, setShown, onRevis
|
||||
}} />
|
||||
|
||||
<Button
|
||||
primary
|
||||
kind="primary"
|
||||
icon="bx bx-download"
|
||||
text={t("revisions.download_button")}
|
||||
onClick={() => {
|
||||
|
||||
@ -35,7 +35,7 @@ export default function UploadAttachmentsDialog() {
|
||||
className="upload-attachments-dialog"
|
||||
size="lg"
|
||||
title={t("upload_attachments.upload_attachments_to_note")}
|
||||
footer={<Button text={t("upload_attachments.upload")} primary disabled={!files || isUploading} />}
|
||||
footer={<Button text={t("upload_attachments.upload")} kind="primary" disabled={!files || isUploading} />}
|
||||
onSubmit={async () => {
|
||||
if (!files || !parentNoteId) {
|
||||
return;
|
||||
|
||||
@ -18,7 +18,7 @@ export interface ButtonProps {
|
||||
keyboardShortcut?: string;
|
||||
/** Called when the button is clicked. If not set, the button will submit the form (if any). */
|
||||
onClick?: () => void;
|
||||
primary?: boolean;
|
||||
kind?: "primary" | "secondary" | "lowProfile";
|
||||
disabled?: boolean;
|
||||
size?: "normal" | "small" | "micro";
|
||||
style?: CSSProperties;
|
||||
@ -26,15 +26,23 @@ export interface ButtonProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, primary, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
|
||||
const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortcut, icon, kind, disabled, size, style, triggerCommand, ...restProps }: ButtonProps) => {
|
||||
// Memoize classes array to prevent recreation
|
||||
const classes = useMemo(() => {
|
||||
const classList: string[] = ["btn"];
|
||||
if (primary) {
|
||||
classList.push("btn-primary");
|
||||
} else {
|
||||
classList.push("btn-secondary");
|
||||
|
||||
switch(kind) {
|
||||
case "primary":
|
||||
classList.push("btn-primary");
|
||||
break;
|
||||
case "lowProfile":
|
||||
classList.push("tn-low-profile");
|
||||
break;
|
||||
default:
|
||||
classList.push("btn-secondary");
|
||||
break;
|
||||
}
|
||||
|
||||
if (className) {
|
||||
classList.push(className);
|
||||
}
|
||||
@ -44,7 +52,7 @@ const Button = memo(({ name, buttonRef, className, text, onClick, keyboardShortc
|
||||
classList.push("btn-micro");
|
||||
}
|
||||
return classList.join(" ");
|
||||
}, [primary, className, size]);
|
||||
}, [kind, className, size]);
|
||||
|
||||
// Memoize keyboard shortcut rendering
|
||||
const shortcutElements = useMemo(() => {
|
||||
|
||||
@ -43,7 +43,7 @@ export default function FilePropertiesTab({ note, ntxId }: Pick<TabContext, "not
|
||||
<Button
|
||||
icon="bx bx-download"
|
||||
text={t("file_properties.download")}
|
||||
primary
|
||||
kind="primary"
|
||||
disabled={!canAccessProtectedNote}
|
||||
onClick={() => downloadFileNote(note, parentComponent, ntxId)}
|
||||
/>
|
||||
|
||||
@ -43,7 +43,7 @@ export default function ImagePropertiesTab({ note, ntxId }: TabContext) {
|
||||
<Button
|
||||
text={t("image_properties.download")}
|
||||
icon="bx bx-download"
|
||||
primary
|
||||
kind="primary"
|
||||
onClick={() => downloadFileNote(note, parentComponent, ntxId)}
|
||||
/>
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ export default function ProtectedSession() {
|
||||
|
||||
<Button
|
||||
text={t("protected_session.start_session_button")}
|
||||
primary
|
||||
kind="primary"
|
||||
keyboardShortcut="Enter"
|
||||
/>
|
||||
</form>
|
||||
|
||||
@ -107,7 +107,7 @@ function DisabledRender({ note }: TypeWidgetProps) {
|
||||
text={t("render.disabled_button_enable")}
|
||||
icon="bx bx-check-shield"
|
||||
onClick={() => attributes.toggleDangerousAttribute(note, "relation", "renderNote", true)}
|
||||
primary
|
||||
kind="primary"
|
||||
/>
|
||||
</SetupForm>
|
||||
);
|
||||
|
||||
@ -74,7 +74,7 @@ function SetupWebView({note}: {note: FNote}) {
|
||||
|
||||
<Button
|
||||
text={t("web_view_setup.create_button")}
|
||||
primary
|
||||
kind="primary"
|
||||
keyboardShortcut="Enter"
|
||||
/>
|
||||
</SetupForm>
|
||||
@ -96,7 +96,7 @@ function DisabledWebView({ note, url }: { note: FNote, url: string }) {
|
||||
text={t("web_view_setup.disabled_button_enable")}
|
||||
icon="bx bx-check-shield"
|
||||
onClick={() => attributes.toggleDangerousAttribute(note, "label", "webViewSrc", true)}
|
||||
primary
|
||||
kind="primary"
|
||||
/>
|
||||
</SetupForm>
|
||||
);
|
||||
|
||||
@ -95,7 +95,7 @@ function ChangePassword() {
|
||||
|
||||
<Button
|
||||
text={t("password.change_password")}
|
||||
primary
|
||||
kind="primary"
|
||||
/>
|
||||
</form>
|
||||
</OptionsSection>
|
||||
|
||||
@ -65,7 +65,7 @@ export function SyncConfiguration() {
|
||||
</FormGroup>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "spaceBetween"}}>
|
||||
<Button text={t("sync_2.save")} primary />
|
||||
<Button text={t("sync_2.save")} kind="primary" />
|
||||
<Button text={t("sync_2.help")} onClick={() => openInAppHelpFromUrl("cbkrhQjrkKrh")} />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user